28 Commits

Author SHA1 Message Date
5ddd1b8ec8 Cleanup 2026-05-11 21:51:18 +02:00
1b9372ff7c Restore dual terrain tilemap rendering 2026-05-10 23:04:48 +02:00
3a52db0071 Finish rewrite task list 2026-05-10 22:35:25 +02:00
5a186fb606 Split simulation systems 2026-05-10 18:49:24 +02:00
6c7fa070f6 cleanup code 2026-05-10 18:37:30 +02:00
d22c4a7528 Update rewrite docs and cleanup 2026-05-10 18:09:43 +02:00
7ffaa140a8 Introduce simulation engine facade 2026-05-10 18:08:03 +02:00
3c5fc60ffe Parameterize surface interactions 2026-05-10 18:07:16 +02:00
9cd9defc0b Unify junction props 2026-05-10 18:05:32 +02:00
1aa9734e08 Split simulation models 2026-05-10 18:03:46 +02:00
a0b10423ac Expand rule event coverage 2026-05-10 17:38:43 +02:00
cb28eee1dd Add branch-aware junction flow 2026-05-10 17:29:19 +02:00
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
79f3219a72 Condense reactor design spec 2026-05-10 14:33:09 +02:00
071e6a1d48 Finalize v1 design spec 2026-05-10 14:27:49 +02:00
810478ddee Refine leak and remediation design 2026-05-10 13:56:20 +02:00
bb8d1adb10 Finalize simulation design document 2026-05-10 13:28:33 +02:00
c8795d582c gitignore 2026-05-10 13:22:17 +02:00
2376edab0d Revise simulation design doc 2026-05-09 13:10:49 +02:00
c406bf9d73 Latest 2026-05-09 12:29:32 +02:00
4b581d60b5 Improve Win2D editor hover feedback 2026-05-09 03:01:26 +02:00
e90609bcee Improve Win2D editor UX 2026-05-09 02:41:05 +02:00
6e8766db3f Add generated Win2D sprites 2026-05-09 02:17:47 +02:00
fd5564e444 Fix tilemap asset loading 2026-05-09 00:48:57 +02:00
70adeb010f Render terrain from tilemap 2026-05-09 00:32:32 +02:00
118 changed files with 4708 additions and 3321 deletions

View File

@@ -20,9 +20,10 @@ insert_final_newline = false
# Organize usings # Organize usings
dotnet_separate_import_directive_groups = false dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = false dotnet_sort_system_directives_first = false
file_header_template = file_header_template = # this. and Me. preferences
# this. and Me. preferences
dotnet_style_qualification_for_event = false:suggestion dotnet_style_qualification_for_event = false:suggestion
dotnet_style_qualification_for_field = false:suggestion dotnet_style_qualification_for_field = false:suggestion
dotnet_style_qualification_for_method = false:suggestion dotnet_style_qualification_for_method = false:suggestion
@@ -125,7 +126,7 @@ csharp_new_line_before_else = true
csharp_new_line_before_finally = true csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = accessors,anonymous_methods,control_blocks,events,indexers,local_functions,methods,properties,types csharp_new_line_before_open_brace = accessors, anonymous_methods, control_blocks, events, indexers, local_functions, methods, properties, types
csharp_new_line_between_query_expression_clauses = true csharp_new_line_between_query_expression_clauses = true
# Indentation preferences # Indentation preferences
@@ -476,7 +477,7 @@ dotnet_naming_rule.types_and_namespaces_rule.import_to_resharper = as_predefined
dotnet_naming_rule.types_and_namespaces_rule.severity = none dotnet_naming_rule.types_and_namespaces_rule.severity = none
dotnet_naming_rule.types_and_namespaces_rule.style = pascal_case dotnet_naming_rule.types_and_namespaces_rule.style = pascal_case
dotnet_naming_rule.types_and_namespaces_rule.symbols = types_and_namespaces_symbols dotnet_naming_rule.types_and_namespaces_rule.symbols = types_and_namespaces_symbols
dotnet_naming_symbols.constants_symbols.applicable_accessibilities = public,internal,protected,protected_internal,private_protected dotnet_naming_symbols.constants_symbols.applicable_accessibilities = public, internal, protected, protected_internal, private_protected
dotnet_naming_symbols.constants_symbols.applicable_kinds = field dotnet_naming_symbols.constants_symbols.applicable_kinds = field
dotnet_naming_symbols.constants_symbols.required_modifiers = const dotnet_naming_symbols.constants_symbols.required_modifiers = const
dotnet_naming_symbols.event_symbols.applicable_accessibilities = * dotnet_naming_symbols.event_symbols.applicable_accessibilities = *
@@ -498,16 +499,16 @@ dotnet_naming_symbols.private_static_fields_symbols.applicable_kinds = field
dotnet_naming_symbols.private_static_fields_symbols.required_modifiers = static dotnet_naming_symbols.private_static_fields_symbols.required_modifiers = static
dotnet_naming_symbols.private_static_readonly_symbols.applicable_accessibilities = private dotnet_naming_symbols.private_static_readonly_symbols.applicable_accessibilities = private
dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds = field dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds = field
dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers = static,readonly dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers = static, readonly
dotnet_naming_symbols.property_symbols.applicable_accessibilities = * dotnet_naming_symbols.property_symbols.applicable_accessibilities = *
dotnet_naming_symbols.property_symbols.applicable_kinds = property dotnet_naming_symbols.property_symbols.applicable_kinds = property
dotnet_naming_symbols.public_fields_symbols.applicable_accessibilities = public,internal,protected,protected_internal,private_protected dotnet_naming_symbols.public_fields_symbols.applicable_accessibilities = public, internal, protected, protected_internal, private_protected
dotnet_naming_symbols.public_fields_symbols.applicable_kinds = field dotnet_naming_symbols.public_fields_symbols.applicable_kinds = field
dotnet_naming_symbols.static_readonly_symbols.applicable_accessibilities = public,internal,protected,protected_internal,private_protected dotnet_naming_symbols.static_readonly_symbols.applicable_accessibilities = public, internal, protected, protected_internal, private_protected
dotnet_naming_symbols.static_readonly_symbols.applicable_kinds = field dotnet_naming_symbols.static_readonly_symbols.applicable_kinds = field
dotnet_naming_symbols.static_readonly_symbols.required_modifiers = static,readonly dotnet_naming_symbols.static_readonly_symbols.required_modifiers = static, readonly
dotnet_naming_symbols.types_and_namespaces_symbols.applicable_accessibilities = * dotnet_naming_symbols.types_and_namespaces_symbols.applicable_accessibilities = *
dotnet_naming_symbols.types_and_namespaces_symbols.applicable_kinds = namespace,class,struct,enum,delegate dotnet_naming_symbols.types_and_namespaces_symbols.applicable_kinds = namespace, class, struct, enum, delegate
# ReSharper inspection severities # ReSharper inspection severities
resharper_arrange_accessor_owner_body_highlighting = suggestion resharper_arrange_accessor_owner_body_highlighting = suggestion

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.vs .vs
bin bin
obj obj
.idea

View File

@@ -1,4 +1,4 @@
# Windows-specific instructions # Windows-specific instructions
- After the implementation is finished, run `python D:\Code\crlf.py $file1 $file2 ...` for changed files you recognize, in order to normalize all line endings of all touched files to CRLF. - After the implementation is finished, run `python D:\Code\crlf.py $file1 $file2 ...` for changed files you recognize, in order to normalize all line endings of all touched files to CRLF.
- After every iteration, run `jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched. - After every iteration, run `jb cleanupcode '$file1' '$file2' ...` for every C# file you touched.

View File

@@ -25,6 +25,7 @@ This repository follows the local `.editorconfig` and the style visible in the c
## Braces And Blocks ## Braces And Blocks
- Use braces for multi-line bodies. - Use braces for multi-line bodies.
- If nesting a for-loop under another for-loop, always include curly braces in the parent for-loop.
- Omit braces for simple single-line embedded statements when readability stays clear. - Omit braces for simple single-line embedded statements when readability stays clear.
- Nested control flow with multi-line bodies should use braces at every multi-line level. - Nested control flow with multi-line bodies should use braces at every multi-line level.
- Keep opening braces on the next line for types, methods, properties, accessors, and control blocks. - Keep opening braces on the next line for types, methods, properties, accessors, and control blocks.

View File

@@ -1,18 +1,28 @@
# 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, explicit leak access faces, door edges, reactor consumer bindings, rule events, 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, validation, serialization, and editor operations.
## Editor Controls
- Left click selects or paints with the current tool. Right click clears the selected cell's prop, surface hazards, leaks, doors, and reactor control.
- Door authoring is explicit: select the Door tool, click the door cell, then click the adjacent floor cell that defines the blocked edge.
- Electricity wall leaks are explicit: select the Electricity Leak tool, click the wall network cell, then click the adjacent floor access face.
- Reactor bindings are explicit: select or place a reactor control, select a matching consumer cell, then use the Fuel, Coolant, or Electric binding action in the inspector.
- Rule event authoring is available from the inspector for next-turn warnings and selected-cell leak events; authored events are saved in the version 2 JSON schema.
## 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.

View File

@@ -1,11 +1,11 @@
<Solution> <Solution>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/ReactorMaintenance.Simulation/ReactorMaintenance.Simulation.csproj" /> <Project Path="src/ReactorMaintenance.Simulation/ReactorMaintenance.Simulation.csproj"/>
<Project Path="src/ReactorMaintenance.Win2D/ReactorMaintenance.Win2D.csproj"> <Project Path="src/ReactorMaintenance.Win2D/ReactorMaintenance.Win2D.csproj">
<Platform Project="x86" /> <Platform Project="x86"/>
</Project> </Project>
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj" /> <Project Path="tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj"/>
</Folder> </Folder>
</Solution> </Solution>

9
TASKS.md Normal file
View File

@@ -0,0 +1,9 @@
# Reactor Maintenance Rewrite Tasks
## Current State
## Completed Work
## Current Work
## Future Work

File diff suppressed because it is too large Load Diff

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

@@ -4,75 +4,158 @@ namespace ReactorMaintenance.Simulation;
public abstract class Balancing public abstract class Balancing
{ {
public float ClampValue(float value)
{
return Math.Clamp(value, MinValue, MaxValue);
}
public EBand Band(float value, float caution, float critical)
{
if (value >= critical)
return EBand.Critical;
return value >= caution ? EBand.Caution : EBand.Safe;
}
public IReadOnlyList<JunctionRatioPreset> JunctionRatios(int outflowCount)
{
return outflowCount switch {
2 => TwoOutflowJunctionRatios,
3 => ThreeOutflowJunctionRatios,
_ => Array.Empty<JunctionRatioPreset>()
};
}
public float[] JunctionWeights(int outflowCount, int mode)
{
var ratios = JunctionRatios(outflowCount);
if (ratios.Count == 0)
return Array.Empty<float>();
return ratios[Math.Clamp(mode, 0, ratios.Count - 1)].Weights;
}
public SurfaceInteractionEffect SameCellInteraction(ECarrierType? rowCarrier, EBand rowBand, ECarrierType? colCarrier, EBand colBand)
{
if (rowBand == EBand.Safe && colBand == EBand.Safe)
return SurfaceInteractionEffect.Hold;
if (rowCarrier == ECarrierType.Fuel && colCarrier == ECarrierType.Electricity)
return Ignite(rowBand, colBand);
if (rowCarrier == ECarrierType.Fuel && colCarrier is null)
return rowBand == EBand.Critical || colBand == EBand.Critical ? Ignite(rowBand, colBand) : Warm(rowBand, colBand);
if (rowCarrier == ECarrierType.Coolant && colCarrier == ECarrierType.Electricity)
return Short(rowBand, colBand);
if (rowCarrier == ECarrierType.Coolant && colCarrier is null)
return Quench(rowBand, colBand);
return SurfaceInteractionEffect.Hold;
}
public SurfaceInteractionEffect FlowInteraction(ESurfaceQuantity quantity)
{
return new() { Verb = ESurfaceInteractionVerb.Flow, Quantity = quantity, Amount = FlowTransferRatio };
}
private SurfaceInteractionEffect Warm(EBand rowBand, EBand colBand)
{
return new() {
Verb = ESurfaceInteractionVerb.Warm,
Quantity = ESurfaceQuantity.Heat,
Amount = Strongest(rowBand, colBand) == EBand.Critical ? WarmCriticalAmount : WarmCautionAmount
};
}
private SurfaceInteractionEffect Quench(EBand rowBand, EBand colBand)
{
return new() {
Verb = ESurfaceInteractionVerb.Quench,
Quantity = ESurfaceQuantity.Heat,
Amount = Strongest(rowBand, colBand) == EBand.Critical ? QuenchCriticalAmount : QuenchCautionAmount
};
}
private SurfaceInteractionEffect Short(EBand rowBand, EBand colBand)
{
var critical = Strongest(rowBand, colBand) == EBand.Critical;
return new() {
Verb = ESurfaceInteractionVerb.Short,
Quantity = ESurfaceQuantity.Electricity,
Amount = critical ? ShortCriticalHeat : ShortCautionHeat,
SecondaryAmount = critical ? ShortCriticalDischarge : ShortCautionDischarge
};
}
private SurfaceInteractionEffect Ignite(EBand rowBand, EBand colBand)
{
var critical = Strongest(rowBand, colBand) == EBand.Critical;
return new() {
Verb = ESurfaceInteractionVerb.Ignite,
Quantity = ESurfaceQuantity.Fuel,
Amount = critical ? IgniteCriticalHeat : IgniteCautionHeat,
SecondaryAmount = critical ? IgniteCriticalFuelConsumption : IgniteCautionFuelConsumption
};
}
private static EBand Strongest(EBand a, EBand b)
{
return a > b ? a : b;
}
public static Balancing Current { get; set; } = new NormalBalancing(); public static Balancing Current { get; set; } = new NormalBalancing();
public abstract int MinHazardValue { get; }
public abstract int MaxHazardValue { get; }
public abstract int DefaultHazardStability { get; }
public abstract int DefaultCellIntegrity { get; }
public abstract int DefaultActionsPerTurn { get; }
public abstract int DefaultCoreHeat { get; }
public abstract int DefaultFacilityStability { get; }
public abstract int DefaultPower { get; }
public abstract int DefaultCooling { get; }
public abstract int FirstGridCoordinate { get; }
public abstract int NeighborDistance { get; }
public abstract int CurrentForecastTurn { get; }
public abstract int MinimumLevelSize { get; }
public abstract int DefaultLevelWidth { get; } public abstract int 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 IReadOnlyList<JunctionRatioPreset> TwoOutflowJunctionRatios { get; }
public abstract int FireHeatIncrease { get; } public abstract IReadOnlyList<JunctionRatioPreset> ThreeOutflowJunctionRatios { get; }
public abstract int FireSmokeIncrease { get; } public abstract float ConsumerRequiredAmount { get; }
public abstract int FireLiquidFuelConsumption { get; } public abstract float ConsumerRequiredIntensity { get; }
public abstract int FireFuelVaporConsumption { get; } public abstract float LeakBaseAmount { get; }
public abstract int SmokeDecay { get; } public abstract float LeakAmountScale { get; }
public abstract int PressurizedFuelLeakPressureThreshold { get; } public abstract float LeakIntensityScale { get; }
public abstract int PassiveFuelVaporHeatOffset { get; } public abstract float FlowTransferRatio { get; }
public abstract int PassiveFuelVaporDivisor { get; } public abstract float WarmCautionAmount { get; }
public abstract int MinimumCoolantHeatReduction { get; } public abstract float WarmCriticalAmount { get; }
public abstract int CoolantHeatReductionDivisor { get; } public abstract float QuenchCautionAmount { get; }
public abstract int CoolantSteamHeatThreshold { get; } public abstract float QuenchCriticalAmount { get; }
public abstract int CoolantSteamSmokeIncrease { get; } public abstract float ShortCautionHeat { get; }
public abstract int PressureLeakSmokeThreshold { get; } public abstract float ShortCautionDischarge { get; }
public abstract int PressureLeakSmokeIncrease { get; } public abstract float ShortCriticalHeat { get; }
public abstract int GeneratorHeatIncrease { get; } public abstract float ShortCriticalDischarge { get; }
public abstract int CoolingPumpHeatReduction { get; } public abstract float IgniteCautionHeat { get; }
public abstract int ReactorHeatIncrease { get; } public abstract float IgniteCautionFuelConsumption { get; }
public abstract int SmokeSpreadThreshold { get; } public abstract float IgniteCriticalHeat { get; }
public abstract int SmokeSpreadIncrease { get; } public abstract float IgniteCriticalFuelConsumption { get; }
public abstract int CriticalCellStabilityThreshold { get; } public abstract int RemedyBlockTurns { get; }
public abstract int MeltdownCoreHeatThreshold { get; } public abstract int HeatShieldSteps { get; }
public abstract int StabilityCollapseThreshold { get; } public abstract int InventoryCapacityPerRemedy { 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,70 @@
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 int FireHeatIncrease => 2; public override IReadOnlyList<JunctionRatioPreset> TwoOutflowJunctionRatios { get; } = [
public override int FireSmokeIncrease => 2; new("0/4", [0, 1]),
public override int FireLiquidFuelConsumption => 1; new("1/3", [0.25f, 0.75f]),
public override int FireFuelVaporConsumption => 1; new("2/2", [0.5f, 0.5f]),
public override int SmokeDecay => 1; new("3/1", [0.75f, 0.25f]),
public override int PressurizedFuelLeakPressureThreshold => 7; new("4/0", [1, 0])
public override int PassiveFuelVaporHeatOffset => 3; ];
public override int PassiveFuelVaporDivisor => 3;
public override int MinimumCoolantHeatReduction => 1; public override IReadOnlyList<JunctionRatioPreset> ThreeOutflowJunctionRatios { get; } = [
public override int CoolantHeatReductionDivisor => 2; new("0/3/3", [0, 0.5f, 0.5f]),
public override int CoolantSteamHeatThreshold => 7; new("3/0/3", [0.5f, 0, 0.5f]),
public override int CoolantSteamSmokeIncrease => 2; new("3/3/0", [0.5f, 0.5f, 0]),
public override int PressureLeakSmokeThreshold => 8; new("2/2/2", [1f / 3f, 1f / 3f, 1f / 3f])
public override int PressureLeakSmokeIncrease => 1; ];
public override int GeneratorHeatIncrease => 1;
public override int CoolingPumpHeatReduction => 2; public override float ConsumerRequiredAmount => 2.5f;
public override int ReactorHeatIncrease => 1; public override float ConsumerRequiredIntensity => 2.5f;
public override int SmokeSpreadThreshold => 6; public override float LeakBaseAmount => 0.5f;
public override int SmokeSpreadIncrease => 1; public override float LeakAmountScale => 0.15f;
public override int CriticalCellStabilityThreshold => 3; public override float LeakIntensityScale => 0.1f;
public override int MeltdownCoreHeatThreshold => 10; public override float FlowTransferRatio => 0.05f;
public override int StabilityCollapseThreshold => 0; public override float WarmCautionAmount => 0.5f;
public override int GeneratorPowerOutput => 3; public override float WarmCriticalAmount => 1.0f;
public override int CoolingPumpOutput => 3; public override float QuenchCautionAmount => 0.6f;
public override int ReactorReadyPowerThreshold => 3; public override float QuenchCriticalAmount => 1.2f;
public override int ReactorReadyCoolingThreshold => 3; public override float ShortCautionHeat => 0.8f;
public override int ReactorReadyCoreHeatThreshold => 8; public override float ShortCautionDischarge => 0.8f;
public override float ShortCriticalHeat => 1.6f;
public override float ShortCriticalDischarge => 1.5f;
public override float IgniteCautionHeat => 1.2f;
public override float IgniteCautionFuelConsumption => 0.4f;
public override float IgniteCriticalHeat => 2.4f;
public override float IgniteCriticalFuelConsumption => 0.8f;
public override int RemedyBlockTurns => 2;
public override int HeatShieldSteps => 3;
public override int InventoryCapacityPerRemedy => 3;
} }

View File

@@ -0,0 +1,20 @@
namespace ReactorMaintenance.Simulation;
public enum EEditorTool
{
Cursor,
Floor,
Wall,
Underground,
Flow,
Consumer,
Junction,
Door,
AllSeeingEyeTerminal,
RemedySupply,
ReactorControl,
Leak,
SurfaceHazard,
Heat,
Robot
}

View File

@@ -0,0 +1,8 @@
namespace ReactorMaintenance.Simulation;
public sealed record EditorToolCommand
{
public EEditorTool Tool { get; init; }
public ECarrierType Carrier { get; init; }
public ERemedyType RemedyType { get; init; }
}

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

@@ -0,0 +1,17 @@
namespace ReactorMaintenance.Simulation;
public static class GridPositionExtensions
{
public static IEnumerable<GridPosition> Neighbors(this GridPosition position)
{
yield return new(position.X, position.Y - 1);
yield return new(position.X + 1, position.Y);
yield return new(position.X, position.Y + 1);
yield return new(position.X - 1, position.Y);
}
public static int ManhattanDistance(this GridPosition position, GridPosition other)
{
return Math.Abs(position.X - other.X) + Math.Abs(position.Y - other.Y);
}
}

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

@@ -0,0 +1,34 @@
namespace ReactorMaintenance.Simulation;
public sealed record JunctionFlow
{
public float WeightFor(GridPosition outgoingBranch)
{
var index = IndexOfOutgoingBranch(outgoingBranch);
if (index < 0)
return 0;
var weights = Balancing.Current.JunctionWeights(OutgoingBranches.Count, Prop.JunctionMode);
return index < weights.Length ? weights[index] : 0;
}
private int IndexOfOutgoingBranch(GridPosition outgoingBranch)
{
for (var i = 0; i < OutgoingBranches.Count; i++)
{
if (OutgoingBranches[i] == outgoingBranch)
return i;
}
return -1;
}
public GridPosition Position { get; init; } = new(0, 0);
public PropState Prop { get; init; } = new();
public ECarrierType Carrier { get; init; }
public IReadOnlyList<GridPosition> Branches { get; init; } = Array.Empty<GridPosition>();
public GridPosition? IncomingBranch { get; init; }
public IReadOnlyList<GridPosition> OutgoingBranches { get; init; } = Array.Empty<GridPosition>();
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
public bool IsValid => Errors.Count == 0;
}

View File

@@ -0,0 +1,97 @@
namespace ReactorMaintenance.Simulation;
public static class JunctionFlowAnalyzer
{
private sealed record SourceBranch(GridPosition Position, int? Distance);
public static IReadOnlyList<JunctionFlow> Analyze(LevelState level)
{
var flows = new List<JunctionFlow>();
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.Junction)
continue;
flows.Add(AnalyzeJunction(level, position, prop));
}
}
return flows;
}
private static JunctionFlow AnalyzeJunction(LevelState level, GridPosition position, PropState prop)
{
var errors = new List<string>();
var carriers = Enum.GetValues<ECarrierType>().Where(carrier => level.GetUnderground(position, carrier).IsPresent).ToArray();
var carrier = carriers.FirstOrDefault();
if (carriers.Length != 1)
errors.Add("Junction must regulate exactly one underground carrier.");
var branches = carriers.Length == 1
? position.Neighbors().Where(level.InBounds).Where(neighbor => level.GetUnderground(neighbor, carrier).CarriesFlow).ToArray()
: Array.Empty<GridPosition>();
if (carriers.Length == 1 && branches.Length is not 3 and not 4)
errors.Add("Junction must have one incoming branch and two or three outgoing branches.");
var sourceBranches = carriers.Length == 1
? branches.Select(branch => new SourceBranch(branch, ShortestDistanceToSource(level, branch, position, carrier)))
.Where(branch => branch.Distance.HasValue)
.ToArray()
: Array.Empty<SourceBranch>();
GridPosition? incomingBranch = null;
if (sourceBranches.Length > 0)
{
var bestDistance = sourceBranches.Min(branch => branch.Distance!.Value);
var bestBranches = sourceBranches.Where(branch => branch.Distance == bestDistance).ToArray();
if (bestBranches.Length != 1 || sourceBranches.Length != 1)
errors.Add("Ambiguous junction flow.");
else
incomingBranch = bestBranches[0].Position;
}
var outgoingBranches = incomingBranch is null
? Array.Empty<GridPosition>()
: branches.Where(branch => branch != incomingBranch).ToArray();
return new() {
Position = position,
Prop = prop,
Carrier = carrier,
Branches = branches,
IncomingBranch = incomingBranch,
OutgoingBranches = outgoingBranches,
Errors = errors
};
}
private static int? ShortestDistanceToSource(LevelState level, GridPosition start, GridPosition blocked, ECarrierType carrier)
{
var visited = new HashSet<GridPosition> { blocked, start };
var open = new Queue<(GridPosition Position, int Distance)>();
open.Enqueue((start, 0));
while (open.Count > 0)
{
var current = open.Dequeue();
if (level.GetProp(current.Position) is { Type: EPropType.Flow, Carrier: var sourceCarrier, SwitchState: EPropSwitchState.Enabled } && sourceCarrier == carrier)
return current.Distance;
foreach (var next in current.Position.Neighbors().Where(level.InBounds))
{
if (!visited.Add(next) || !level.GetUnderground(next, carrier).CarriesFlow)
continue;
open.Enqueue((next, current.Distance + 1));
}
}
return null;
}
}

View File

@@ -0,0 +1,3 @@
namespace ReactorMaintenance.Simulation;
public sealed record JunctionRatioPreset(string Label, float[] Weights);

View File

@@ -1,117 +1,168 @@
namespace ReactorMaintenance.Simulation; namespace ReactorMaintenance.Simulation;
public enum EEditorTool
{
Floor,
Wall,
Reactor,
CoolingPump,
Generator,
PressureRegulator,
DiagnosticTerminal,
ControlTerminal,
CoolantPipe,
FuelPipe,
PressurePipe,
Leak,
Repair,
Heat,
Fire,
Robot
}
public static class LevelEditor public static class LevelEditor
{ {
public static LevelState Apply(LevelState level, GridPosition position, EEditorTool tool) public static LevelState Apply(LevelState level, GridPosition position, EditorToolCommand command)
{ {
if (!level.InBounds(position)) if (!level.InBounds(position))
return level; return level;
if (tool == EEditorTool.Robot) return command.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.Underground => SetUnderground(level, position, command.Carrier),
EEditorTool.Floor => cell with { Terrain = ECellTerrain.Floor }, EEditorTool.Flow => SetCarrierProp(level, position, EPropType.Flow, command.Carrier),
EEditorTool.Wall => cell with { EEditorTool.Consumer => SetCarrierProp(level, position, EPropType.Consumer, command.Carrier),
Terrain = ECellTerrain.Wall, EEditorTool.Junction => SetFloorProp(level, position, new() { Type = EPropType.Junction }),
Prop = ECellProp.None, EEditorTool.Door => SetFloorProp(level, position, new() { Type = EPropType.Door }),
Pipe = EPipeMedium.None, EEditorTool.AllSeeingEyeTerminal => SetFloorProp(level, position, new() { Type = EPropType.AllSeeingEyeTerminal }),
Flow = Balancing.Current.MinHazardValue, EEditorTool.RemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = command.RemedyType }),
Pressure = Balancing.Current.MinHazardValue, EEditorTool.ReactorControl => SetReactorControl(level, position),
LeakRate = Balancing.Current.MinHazardValue, EEditorTool.Leak => SetLeak(level, position, command.Carrier),
PipeOpen = false, EEditorTool.SurfaceHazard => AddSurfaceHazard(level, position, command.Carrier),
Powered = false EEditorTool.Heat => level.SetSurface(position, level.GetSurface(position) with { Heat = level.GetSurface(position).Heat + 1 }),
}, EEditorTool.Robot => level.IsFloor(position) ? level with { Robot = level.Robot with { Position = position } } : level,
EEditorTool.Reactor => cell with { Terrain = ECellTerrain.Floor, Prop = ECellProp.Reactor }, _ => level
EEditorTool.CoolingPump => cell with {
Terrain = ECellTerrain.Floor,
Prop = ECellProp.CoolingPump,
Powered = true
},
EEditorTool.Generator => cell with {
Terrain = ECellTerrain.Floor,
Prop = ECellProp.Generator,
Powered = true
},
EEditorTool.PressureRegulator => cell with { Terrain = ECellTerrain.Floor, Prop = ECellProp.PressureRegulator },
EEditorTool.DiagnosticTerminal => cell with {
Terrain = ECellTerrain.Floor,
Prop = ECellProp.DiagnosticTerminal,
Powered = true
},
EEditorTool.ControlTerminal => cell with {
Terrain = ECellTerrain.Floor,
Prop = ECellProp.ControlTerminal,
Powered = true
},
EEditorTool.CoolantPipe => cell with {
Pipe = EPipeMedium.Coolant,
Flow = Balancing.Current.DefaultPipeFlow,
Pressure = Balancing.Current.DefaultPipePressure,
Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity),
PipeOpen = true
},
EEditorTool.FuelPipe => cell with {
Pipe = EPipeMedium.Fuel,
Flow = Balancing.Current.DefaultPipeFlow,
Pressure = Balancing.Current.DefaultPipePressure,
Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity),
PipeOpen = true
},
EEditorTool.PressurePipe => cell with {
Pipe = EPipeMedium.Pressure,
Flow = Balancing.Current.DefaultPressurePipeFlow,
Pressure = Balancing.Current.DefaultPressurePipePressure,
Integrity = Math.Max(cell.Integrity, Balancing.Current.DefaultEditedPipeIntegrity),
PipeOpen = true
},
EEditorTool.Leak => cell with {
LeakRate = Math.Max(Balancing.Current.MinimumLeakRate, cell.LeakRate),
Integrity = Math.Min(cell.Integrity, Balancing.Current.DamagedPipeIntegrity)
},
EEditorTool.Repair => cell with {
LeakRate = Balancing.Current.RepairedLeakRate,
Integrity = Balancing.Current.DefaultCellIntegrity,
Hazards = cell.Hazards with {
Fire = false,
ElectricalCharge = Balancing.Current.RepairedElectricalCharge
}
},
EEditorTool.Heat => cell with { Hazards = cell.Hazards with { Heat = Rules.Clamp(cell.Hazards.Heat + Balancing.Current.HeatToolIncrease) } },
EEditorTool.Fire => cell with {
Hazards = cell.Hazards with {
Fire = !cell.Hazards.Fire,
Heat = Math.Max(cell.Hazards.Heat, Balancing.Current.FireToolMinimumHeat),
Smoke = Math.Max(cell.Hazards.Smoke, Balancing.Current.FireToolMinimumSmoke)
}
},
_ => cell
}; };
}
if (cell.Terrain == ECellTerrain.Wall) public static LevelState SetDoorEdge(LevelState level, GridPosition a, GridPosition b)
cell = cell with { Hazards = new() }; {
if (!level.IsFloor(a) || !level.IsFloor(b) || a.ManhattanDistance(b) != 1)
return level;
return level.SetCell(position, cell); return level.SetProp(a, new() { Type = EPropType.Door }) with {
Doors = [
.. level.Doors.Where(door => !SameDoorEdge(door, a, b)),
new() { A = a, B = b, State = EDoorState.Closed }
]
};
}
public static LevelState SetLeak(LevelState level, GridPosition undergroundPosition, GridPosition accessPosition, ECarrierType carrier)
{
if (!level.InBounds(undergroundPosition) || !level.IsFloor(accessPosition))
return level;
if (carrier is ECarrierType.Fuel or ECarrierType.Coolant && undergroundPosition != accessPosition)
return level;
if (carrier == ECarrierType.Electricity && undergroundPosition.ManhattanDistance(accessPosition) != 1)
return level;
var next = level.SetUnderground(undergroundPosition, carrier, new() { State = EUndergroundState.Leaking });
return next with {
Leaks = [
.. next.Leaks.Where(leak => leak.UndergroundPosition != undergroundPosition || leak.Carrier != carrier),
new() {
Carrier = carrier,
UndergroundPosition = undergroundPosition,
AccessPosition = accessPosition
}
]
};
}
public static LevelState BindReactorConsumer(LevelState level, int reactorId, ECarrierType carrier, GridPosition consumerPosition)
{
if (!level.InBounds(consumerPosition) || level.GetProp(consumerPosition) is not { Type: EPropType.Consumer } prop || prop.Carrier != carrier)
return level;
var reactors = level.Reactors.Select(reactor => reactor.ReactorId == reactorId ? BindConsumer(reactor, carrier, consumerPosition) : reactor).ToArray();
return level with { Reactors = reactors };
}
public static LevelState AddRuleEvent(LevelState level, RuleEventState ruleEvent)
{
var id = string.IsNullOrWhiteSpace(ruleEvent.Id) ? NextRuleEventId(level) : ruleEvent.Id;
var authoredEvent = ruleEvent with { Id = id };
return level with {
RuleEvents = [.. level.RuleEvents.Where(existing => existing.Id != id), authoredEvent]
};
}
public static LevelState RemoveRuleEvent(LevelState level, string id)
{
return level with { RuleEvents = level.RuleEvents.Where(ruleEvent => ruleEvent.Id != id).ToArray() };
}
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 AddSurfaceHazard(LevelState level, GridPosition position, ECarrierType carrier)
{
var surface = level.GetSurface(position);
return carrier switch {
ECarrierType.Fuel => level.SetSurface(position, surface with { Fuel = surface.Fuel + 1 }),
ECarrierType.Coolant => level.SetSurface(position, surface with { Coolant = surface.Coolant + 1 }),
ECarrierType.Electricity => level.SetSurface(position, surface with { Electricity = surface.Electricity + 1 }),
_ => level
};
}
private static LevelState SetFloorProp(LevelState level, GridPosition position, PropState prop)
{
return level.IsFloor(position) ? level.SetProp(position, prop) : level;
}
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() {
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;
return SetLeak(level, position, position, carrier);
}
private static bool SameDoorEdge(DoorState door, GridPosition a, GridPosition b)
{
return (door.A == a && door.B == b) || (door.A == b && door.B == a);
}
private static ReactorBinding BindConsumer(ReactorBinding reactor, ECarrierType carrier, GridPosition consumerPosition)
{
return carrier switch {
ECarrierType.Fuel => reactor with { FuelConsumerPosition = consumerPosition },
ECarrierType.Coolant => reactor with { CoolantConsumerPosition = consumerPosition },
ECarrierType.Electricity => reactor with { ElectricityConsumerPosition = consumerPosition },
_ => reactor
};
}
private static string NextRuleEventId(LevelState level)
{
var next = level.RuleEvents.Count + 1;
while (level.RuleEvents.Any(ruleEvent => ruleEvent.Id == $"rule-{next}"))
next++;
return $"rule-{next}";
} }
} }

View File

@@ -5,35 +5,38 @@ namespace ReactorMaintenance.Simulation;
public static class LevelSerializer public static class LevelSerializer
{ {
private const int c_CurrentVersion = 1; private sealed record LevelFile
{
public int Version { get; init; }
public LevelState? Level { get; init; }
}
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 const int c_CurrentVersion = 2;
private static readonly JsonSerializerOptions s_Options = new() {
WriteIndented = true, WriteIndented = true,
Converters = { new JsonStringEnumConverter() } Converters = { new JsonStringEnumConverter() }
}; };
private sealed record LevelFile
{
public int Version { get; init; }
public LevelState Level { get; init; } = new();
}
} }

View File

@@ -0,0 +1,116 @@
namespace ReactorMaintenance.Simulation;
public static class LevelStateExtensions
{
public static bool InBounds(this LevelState level, GridPosition position)
{
return position.X >= 0 && position.Y >= 0 && position.X < level.Width && position.Y < level.Height;
}
public static int Index(this LevelState level, GridPosition position)
{
if (!level.InBounds(position))
throw new ArgumentOutOfRangeException(nameof(position), $"Position {position.X},{position.Y} is outside {level.Width}x{level.Height}.");
return (position.Y * level.Width) + position.X;
}
public static ECellTerrain GetTerrain(this LevelState level, GridPosition position)
{
return level.Terrain[level.Index(position)];
}
public static UndergroundCell GetUnderground(this LevelState level, GridPosition position, ECarrierType carrier)
{
return level.Layer(carrier)[level.Index(position)];
}
public static SurfaceState GetSurface(this LevelState level, GridPosition position)
{
return level.Surface[level.Index(position)];
}
public static PropState GetProp(this LevelState level, GridPosition position)
{
return level.Props[level.Index(position)];
}
public static bool IsFloor(this LevelState level, GridPosition position)
{
return level.InBounds(position) && level.GetTerrain(position) == ECellTerrain.Floor;
}
public static bool IsClosedDoorEdge(this LevelState level, GridPosition a, GridPosition b)
{
return level.Doors.Any(door => door.State == EDoorState.Closed && SameEdge(door.A, door.B, a, b));
}
public static LevelState SetTerrain(this LevelState level, GridPosition position, ECellTerrain terrain)
{
var next = level.Terrain.ToArray();
next[level.Index(position)] = terrain;
var updated = level with { Terrain = next };
return terrain == ECellTerrain.Wall ? updated.ClearFloorOnlyState(position) : updated;
}
public static LevelState SetUnderground(this LevelState level, GridPosition position, ECarrierType carrier, UndergroundCell cell)
{
var next = level.Layer(carrier).ToArray();
next[level.Index(position)] = cell;
return carrier switch {
ECarrierType.Fuel => level with { Fuel = next },
ECarrierType.Coolant => level with { Coolant = next },
ECarrierType.Electricity => level with { Electricity = next },
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
public static LevelState SetSurface(this LevelState level, GridPosition position, SurfaceState surface)
{
var next = level.Surface.ToArray();
next[level.Index(position)] = surface.Clamp();
return level with { Surface = next };
}
public static LevelState SetProp(this LevelState level, GridPosition position, PropState prop)
{
var next = level.Props.ToArray();
next[level.Index(position)] = prop;
return level with { Props = next };
}
public static LevelState WithRuntimeArrays(this LevelState level, UndergroundCell[] fuel, UndergroundCell[] coolant, UndergroundCell[] electricity, SurfaceState[] surface, PropState[] props)
{
return level with {
Fuel = fuel,
Coolant = coolant,
Electricity = electricity,
Surface = surface,
Props = props
};
}
public static IReadOnlyList<UndergroundCell> Layer(this LevelState level, ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => level.Fuel,
ECarrierType.Coolant => level.Coolant,
ECarrierType.Electricity => level.Electricity,
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
private static LevelState ClearFloorOnlyState(this LevelState level, GridPosition position)
{
return level.SetSurface(position, new())
.SetProp(position, new())
.SetUnderground(position, ECarrierType.Fuel, new())
.SetUnderground(position, ECarrierType.Coolant, new())
.SetUnderground(position, ECarrierType.Electricity, new());
}
private static bool SameEdge(GridPosition edgeA, GridPosition edgeB, GridPosition a, GridPosition b)
{
return (edgeA == a && edgeB == b) || (edgeA == b && edgeB == a);
}
}

View File

@@ -0,0 +1,54 @@
namespace ReactorMaintenance.Simulation;
public static class LevelStateFactory
{
public static LevelState Create(string name, int width, int height)
{
if (width < Balancing.Current.MinimumLevelSize || height < Balancing.Current.MinimumLevelSize)
throw new ArgumentOutOfRangeException(nameof(width), $"Levels must be at least {Balancing.Current.MinimumLevelSize}x{Balancing.Current.MinimumLevelSize}.");
return new() {
Name = name,
Width = width,
Height = height,
Terrain = CreateTerrain(width, height),
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) },
Forecasts = Array.Empty<Forecast>()
};
}
public static ECellTerrain[] CreateTerrain(int width, int height)
{
var terrain = Enumerable.Repeat(ECellTerrain.Floor, width * height).ToArray();
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
if (x == 0 || y == 0 || x == width - 1 || y == height - 1)
terrain[(y * width) + x] = ECellTerrain.Wall;
}
}
return terrain;
}
public static UndergroundCell[] CreateUnderground(int width, int height)
{
return Enumerable.Range(0, width * height).Select(_ => new UndergroundCell()).ToArray();
}
public static SurfaceState[] CreateSurface(int width, int height)
{
return Enumerable.Range(0, width * height).Select(_ => new SurfaceState()).ToArray();
}
public static PropState[] CreateProps(int width, int height)
{
return Enumerable.Range(0, width * height).Select(_ => new PropState()).ToArray();
}
}

View File

@@ -0,0 +1,13 @@
namespace ReactorMaintenance.Simulation;
internal static class LevelTraversal
{
public 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);
}
}
}

View File

@@ -0,0 +1,284 @@
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)
{
foreach (var junction in JunctionFlowAnalyzer.Analyze(level))
errors.AddRange(junction.Errors.Select(error => new ValidationIssue(error, junction.Position)));
}
private static void ValidateRuleEvents(LevelState level, List<ValidationIssue> errors)
{
foreach (var ruleEvent in level.RuleEvents)
{
foreach (var predicate in ruleEvent.Predicates)
ValidateRulePredicate(level, predicate, errors);
foreach (var effect in ruleEvent.Effects)
ValidateRuleEffect(level, effect, errors);
}
}
private static void ValidateRulePredicate(LevelState level, RulePredicate predicate, List<ValidationIssue> errors)
{
switch (predicate.Kind)
{
case ERulePredicateKind.PropStateAt:
if (!level.InBounds(predicate.Position) || level.GetProp(predicate.Position).Type == EPropType.None)
errors.Add(new("Rule prop predicate must target a prop.", predicate.Position));
break;
case ERulePredicateKind.ConsumerStateAt:
if (!IsProp(level, predicate.Position, EPropType.Consumer))
errors.Add(new("Rule consumer predicate must target a consumer prop.", predicate.Position));
break;
case ERulePredicateKind.NetworkBandAt:
if (!level.InBounds(predicate.Position) || !level.GetUnderground(predicate.Position, predicate.Carrier).IsPresent)
errors.Add(new("Rule network predicate must target an underground cell.", predicate.Position));
break;
case ERulePredicateKind.SurfaceBandAt:
case ERulePredicateKind.RobotAt:
if (!level.IsFloor(predicate.Position))
errors.Add(new("Rule floor predicate must target a floor cell.", predicate.Position));
break;
case ERulePredicateKind.ReactorReadyIs:
case ERulePredicateKind.ReactorWonIs:
ValidateOptionalReactorId(level, predicate.ReactorId, predicate.Position, errors);
break;
}
}
private static void ValidateRuleEffect(LevelState level, RuleEffect effect, List<ValidationIssue> errors)
{
if (RequiresNonNegativeAmount(effect.Kind) && effect.Amount < 0)
errors.Add(new("Rule effect amount must be non-negative.", effect.Position));
switch (effect.Kind)
{
case ERuleEffectKind.StartLeak:
ValidateRuleLeakEffect(level, effect, errors);
break;
case ERuleEffectKind.WorsenLeak:
case ERuleEffectKind.RepairNetworkCell:
case ERuleEffectKind.DisableNetworkCell:
if (!level.InBounds(effect.Position) || !level.GetUnderground(effect.Position, effect.Carrier).IsPresent)
errors.Add(new("Rule network effect must target an underground cell.", effect.Position));
break;
case ERuleEffectKind.SetPropEnabled:
if (!level.InBounds(effect.Position) || level.GetProp(effect.Position).Type == EPropType.None)
errors.Add(new("Rule prop effect must target a prop.", effect.Position));
break;
case ERuleEffectKind.AddSurfaceHazard:
case ERuleEffectKind.RemoveSurfaceHazard:
case ERuleEffectKind.AddHeat:
case ERuleEffectKind.RemoveHeat:
if (!level.IsFloor(effect.Position))
errors.Add(new("Rule surface effect must target a floor cell.", effect.Position));
break;
}
}
private static bool RequiresNonNegativeAmount(ERuleEffectKind kind)
{
return kind is ERuleEffectKind.AddSurfaceHazard
or ERuleEffectKind.RemoveSurfaceHazard
or ERuleEffectKind.AddHeat
or ERuleEffectKind.RemoveHeat
or ERuleEffectKind.AddInventory
or ERuleEffectKind.RemoveInventory;
}
private static void ValidateRuleLeakEffect(LevelState level, RuleEffect effect, List<ValidationIssue> errors)
{
var accessPosition = effect.AccessPosition ?? effect.Position;
if (!level.InBounds(effect.Position) || !level.GetUnderground(effect.Position, effect.Carrier).IsPresent)
errors.Add(new("Rule leak effect must target an underground cell.", effect.Position));
if (!level.IsFloor(accessPosition))
{
errors.Add(new("Rule leak effect must have valid floor access.", accessPosition));
return;
}
if (effect.Carrier is ECarrierType.Fuel or ECarrierType.Coolant && effect.Position != accessPosition)
errors.Add(new("Rule fuel and coolant leak effects must use their underground coordinate as access.", accessPosition));
if (effect.Carrier == ECarrierType.Electricity && effect.Position.ManhattanDistance(accessPosition) != 1)
errors.Add(new("Rule electricity leak effect access must be an adjacent floor face.", accessPosition));
}
private static void ValidateOptionalReactorId(LevelState level, int reactorId, GridPosition position, List<ValidationIssue> errors)
{
if (reactorId > 0 && level.Reactors.All(reactor => reactor.ReactorId != reactorId))
errors.Add(new("Rule reactor predicate must reference an existing reactor.", position));
}
private static void ValidateWarnings(LevelState level, List<ValidationIssue> warnings)
{
foreach (var carrier in Enum.GetValues<ECarrierType>())
{
for (var y = 0; y < level.Height; y++)
{
for (var x = 0; x < level.Width; x++)
{
var position = new GridPosition(x, y);
if (level.GetUnderground(position, carrier).IsPresent && !HasSourcePath(level, position, carrier))
warnings.Add(new($"Underground {carrier} cell has no source path.", position));
}
}
}
for (var y = 0; y < level.Height; y++)
{
for (var x = 0; x < level.Width; x++)
{
var position = new GridPosition(x, y);
var prop = level.GetProp(position);
if (prop.Type == EPropType.Consumer && prop.SwitchState == EPropSwitchState.Enabled && !HasSourcePath(level, position, prop.Carrier))
warnings.Add(new("Enabled consumer is initially starved.", position));
}
}
}
private static bool HasSourcePath(LevelState level, GridPosition start, ECarrierType carrier)
{
if (!level.GetUnderground(start, carrier).CarriesFlow)
return false;
var visited = new HashSet<GridPosition>();
var open = new Queue<GridPosition>();
open.Enqueue(start);
visited.Add(start);
while (open.Count > 0)
{
var current = open.Dequeue();
if (level.GetProp(current) is { Type: EPropType.Flow, Carrier: var sourceCarrier, SwitchState: EPropSwitchState.Enabled } && sourceCarrier == carrier)
return true;
foreach (var next in current.Neighbors().Where(level.InBounds))
{
if (!visited.Add(next) || !level.GetUnderground(next, carrier).CarriesFlow)
continue;
open.Enqueue(next);
}
}
return false;
}
private static bool IsProp(LevelState level, GridPosition position, EPropType propType)
{
return level.InBounds(position) && level.GetProp(position).Type == propType;
}
}

View File

@@ -1,181 +0,0 @@
namespace ReactorMaintenance.Simulation;
public enum ECellTerrain
{
Floor,
Wall
}
public enum ECellProp
{
None,
Reactor,
CoolingPump,
Generator,
PressureRegulator,
DiagnosticTerminal,
ControlTerminal
}
public enum EPipeMedium
{
None,
Pressure,
Coolant,
Fuel
}
public enum EFailureKind
{
PipeBurst,
Ignition,
Meltdown,
StabilityCollapse,
ReactorReady
}
public sealed record GridPosition(int X, int Y)
{
public IEnumerable<GridPosition> Neighbors()
{
yield return new(X - Balancing.Current.NeighborDistance, Y);
yield return new(X + Balancing.Current.NeighborDistance, Y);
yield return new(X, Y - Balancing.Current.NeighborDistance);
yield return new(X, Y + Balancing.Current.NeighborDistance);
}
}
public sealed record HazardState
{
public HazardState Clamp()
{
return this with {
Heat = Rules.Clamp(Heat),
Smoke = Rules.Clamp(Smoke),
FuelVapor = Rules.Clamp(FuelVapor),
LiquidFuel = Rules.Clamp(LiquidFuel),
CoolantPooling = Rules.Clamp(CoolantPooling),
ElectricalCharge = Rules.Clamp(ElectricalCharge),
Stability = Rules.Clamp(Stability)
};
}
public int Heat { get; init; }
public int Smoke { get; init; }
public int FuelVapor { get; init; }
public int LiquidFuel { get; init; }
public int CoolantPooling { get; init; }
public int ElectricalCharge { get; init; }
public int Stability { get; init; } = Balancing.Current.DefaultHazardStability;
public bool Fire { get; init; }
}
public sealed record CellState
{
public ECellTerrain Terrain { get; init; } = ECellTerrain.Floor;
public ECellProp Prop { get; init; }
public EPipeMedium Pipe { get; init; }
public int Flow { get; init; }
public int Pressure { get; init; }
public int Integrity { get; init; } = Balancing.Current.DefaultCellIntegrity;
public int LeakRate { get; init; }
public bool PipeOpen { get; init; } = true;
public bool Powered { get; init; }
public bool DoorLocked { get; init; }
public HazardState Hazards { get; init; } = new();
public bool IsWalkable => Terrain != ECellTerrain.Wall;
public bool HasPipe => Pipe != EPipeMedium.None;
}
public sealed record GlobalState
{
public int Turn { get; init; }
public int ActionsPerTurn { get; init; } = Balancing.Current.DefaultActionsPerTurn;
public int CoreHeat { get; init; } = Balancing.Current.DefaultCoreHeat;
public int FacilityStability { get; init; } = Balancing.Current.DefaultFacilityStability;
public int Power { get; init; } = Balancing.Current.DefaultPower;
public int Cooling { get; init; } = Balancing.Current.DefaultCooling;
public bool ReactorActivated { get; init; }
public bool Lost { get; init; }
public string Status { get; init; } = "STABILIZE SYSTEMS";
}
public sealed record Forecast(EFailureKind Kind, GridPosition? Position, int Turns, string Message);
public sealed record LevelState
{
public static LevelState Create(string name, int width, int height)
{
if (width < Balancing.Current.MinimumLevelSize || height < Balancing.Current.MinimumLevelSize)
throw new ArgumentOutOfRangeException(nameof(width), $"Levels must be at least {Balancing.Current.MinimumLevelSize}x{Balancing.Current.MinimumLevelSize}.");
var cells = CreateCells(width, height);
for (var y = Balancing.Current.FirstGridCoordinate; y < height; y++)
{
for (var x = Balancing.Current.FirstGridCoordinate; x < width; x++)
{
if (x == Balancing.Current.FirstGridCoordinate || y == Balancing.Current.FirstGridCoordinate || x == width - Balancing.Current.NeighborDistance || y == height - Balancing.Current.NeighborDistance)
cells[y * width + x] = cells[y * width + x] with { Terrain = ECellTerrain.Wall };
}
}
return new() {
Name = name,
Width = width,
Height = height,
Cells = cells,
Robot = new(Balancing.Current.DefaultRobotCoordinate, Balancing.Current.DefaultRobotCoordinate)
};
}
public CellState GetCell(GridPosition position)
{
EnsureInBounds(position);
return Cells[Index(position)];
}
public LevelState SetCell(GridPosition position, CellState cell)
{
EnsureInBounds(position);
var cells = Cells.ToArray();
cells[Index(position)] = cell;
return this with { Cells = cells };
}
public bool InBounds(GridPosition position)
{
return position.X >= Balancing.Current.FirstGridCoordinate && position.Y >= Balancing.Current.FirstGridCoordinate && position.X < Width && position.Y < Height;
}
public int Index(GridPosition position)
{
return position.Y * Width + position.X;
}
private void EnsureInBounds(GridPosition position)
{
if (!InBounds(position))
throw new ArgumentOutOfRangeException(nameof(position), $"Position {position.X},{position.Y} is outside {Width}x{Height}.");
}
private static CellState[] CreateCells(int width, int height)
{
return Enumerable.Range(Balancing.Current.FirstGridCoordinate, width * height).Select(_ => new CellState()).ToArray();
}
public string Name { get; init; } = "New Reactor";
public int Width { get; init; } = Balancing.Current.DefaultLevelWidth;
public int Height { get; init; } = Balancing.Current.DefaultLevelHeight;
public CellState[] Cells { get; init; } = CreateCells(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public GridPosition Robot { get; init; } = new(Balancing.Current.DefaultRobotCoordinate, Balancing.Current.DefaultRobotCoordinate);
public GlobalState Global { get; init; } = new();
public IReadOnlyList<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

@@ -0,0 +1,8 @@
namespace ReactorMaintenance.Simulation;
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;
}

View File

@@ -0,0 +1,8 @@
namespace ReactorMaintenance.Simulation;
public enum EBand
{
Safe,
Caution,
Critical
}

View File

@@ -0,0 +1,8 @@
namespace ReactorMaintenance.Simulation;
public enum ECarrierType
{
Fuel,
Coolant,
Electricity
}

View File

@@ -0,0 +1,7 @@
namespace ReactorMaintenance.Simulation;
public enum ECellTerrain
{
Floor,
Wall
}

View File

@@ -0,0 +1,10 @@
namespace ReactorMaintenance.Simulation;
public enum EConsumerServiceState
{
Unknown,
Disabled,
Starved,
Supplied,
Producing
}

View File

@@ -0,0 +1,7 @@
namespace ReactorMaintenance.Simulation;
public enum EDoorState
{
Open,
Closed
}

View File

@@ -0,0 +1,10 @@
namespace ReactorMaintenance.Simulation;
public enum EForecastKind
{
TerminalLoss,
ReactorReady,
ConsumerStarved,
HazardGrowth,
RuleEvent
}

View File

@@ -0,0 +1,11 @@
namespace ReactorMaintenance.Simulation;
public enum ELevelState
{
Stable,
Caution,
Critical,
Ready,
Lost,
Won
}

View File

@@ -0,0 +1,7 @@
namespace ReactorMaintenance.Simulation;
public enum ENetworkValueKind
{
Amount,
Intensity
}

View File

@@ -0,0 +1,7 @@
namespace ReactorMaintenance.Simulation;
public enum EPropSwitchState
{
Disabled,
Enabled
}

View File

@@ -0,0 +1,13 @@
namespace ReactorMaintenance.Simulation;
public enum EPropType
{
None,
Flow,
Consumer,
Junction,
Door,
AllSeeingEyeTerminal,
RemedySupply,
ReactorControl
}

View File

@@ -0,0 +1,9 @@
namespace ReactorMaintenance.Simulation;
public enum ERemedyType
{
FuelNeutralizer,
CoolantNeutralizer,
ElectricityNeutralizer,
HeatShield
}

View File

@@ -0,0 +1,18 @@
namespace ReactorMaintenance.Simulation;
public enum ERuleEffectKind
{
StartLeak,
WorsenLeak,
RepairNetworkCell,
DisableNetworkCell,
SetPropEnabled,
AddSurfaceHazard,
RemoveSurfaceHazard,
AddHeat,
RemoveHeat,
AddInventory,
RemoveInventory,
MarkTerminalLoss,
EmitWarning
}

View File

@@ -0,0 +1,7 @@
namespace ReactorMaintenance.Simulation;
public enum ERuleEventPhase
{
StartOfSimulation,
EndOfTurn
}

View File

@@ -0,0 +1,17 @@
namespace ReactorMaintenance.Simulation;
public enum ERulePredicateKind
{
TurnAtLeast,
LevelStateIs,
ReactorReadyIs,
ReactorLostIs,
ReactorWonIs,
PropStateAt,
ConsumerStateAt,
NetworkBandAt,
SurfaceBandAt,
RobotAt,
RobotInventoryAtLeast,
AllSeeingEyeUnlocked
}

View File

@@ -0,0 +1,11 @@
namespace ReactorMaintenance.Simulation;
public enum ESurfaceInteractionVerb
{
Hold,
Flow,
Warm,
Quench,
Short,
Ignite
}

View File

@@ -0,0 +1,9 @@
namespace ReactorMaintenance.Simulation;
public enum ESurfaceQuantity
{
Fuel,
Coolant,
Electricity,
Heat
}

View File

@@ -0,0 +1,8 @@
namespace ReactorMaintenance.Simulation;
public enum EUndergroundState
{
Absent,
Intact,
Leaking
}

View File

@@ -0,0 +1,3 @@
namespace ReactorMaintenance.Simulation;
public sealed record Forecast(EForecastKind Kind, GridPosition? Position, int Turns, string Message);

View File

@@ -0,0 +1,12 @@
namespace ReactorMaintenance.Simulation;
public sealed record GlobalState
{
public int Turn { get; init; }
public int ActionsRemaining { get; init; } = Balancing.Current.ActionsPerTurn;
public ELevelState LevelState { get; init; } = ELevelState.Stable;
public string Status { get; init; } = "STABLE";
public bool AllSeeingEyeUnlocked { get; init; }
public bool TerminalLoss { get; init; }
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,3 @@
namespace ReactorMaintenance.Simulation;
public sealed record GridPosition(int X, int Y);

View File

@@ -0,0 +1,9 @@
namespace ReactorMaintenance.Simulation;
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; }
}

View File

@@ -0,0 +1,26 @@
namespace ReactorMaintenance.Simulation;
public sealed record LevelState
{
public static LevelState Create(string name, int width, int height)
{
return LevelStateFactory.Create(name, width, height);
}
public string Name { get; init; } = "New Reactor";
public int Width { get; init; } = Balancing.Current.DefaultLevelWidth;
public int Height { get; init; } = Balancing.Current.DefaultLevelHeight;
public ECellTerrain[] Terrain { get; init; } = LevelStateFactory.CreateTerrain(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public UndergroundCell[] Fuel { get; init; } = LevelStateFactory.CreateUnderground(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public UndergroundCell[] Coolant { get; init; } = LevelStateFactory.CreateUnderground(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public UndergroundCell[] Electricity { get; init; } = LevelStateFactory.CreateUnderground(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public SurfaceState[] Surface { get; init; } = LevelStateFactory.CreateSurface(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public PropState[] Props { get; init; } = LevelStateFactory.CreateProps(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public IReadOnlyList<DoorState> Doors { get; init; } = Array.Empty<DoorState>();
public IReadOnlyList<LeakState> Leaks { get; init; } = Array.Empty<LeakState>();
public IReadOnlyList<ReactorBinding> Reactors { get; init; } = Array.Empty<ReactorBinding>();
public IReadOnlyList<RuleEventState> RuleEvents { get; init; } = Array.Empty<RuleEventState>();
public RobotState Robot { get; init; } = new();
public GlobalState Global { get; init; } = new();
public IReadOnlyList<Forecast> Forecasts { get; init; } = Array.Empty<Forecast>();
}

View File

@@ -0,0 +1,15 @@
namespace ReactorMaintenance.Simulation;
public sealed record PropState
{
public EPropType Type { get; init; }
public ECarrierType Carrier { get; init; }
public EPropSwitchState SwitchState { get; init; } = EPropSwitchState.Enabled;
public EConsumerServiceState ServiceState { get; init; } = EConsumerServiceState.Unknown;
public int JunctionMode { get; init; }
public ERemedyType RemedyType { get; init; }
public bool Depleted { get; init; }
public int ReactorId { get; init; }
public bool IsEnabled => SwitchState == EPropSwitchState.Enabled;
}

View File

@@ -0,0 +1,12 @@
namespace ReactorMaintenance.Simulation;
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; }
}

View File

@@ -0,0 +1,11 @@
namespace ReactorMaintenance.Simulation;
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; }
}

View File

@@ -0,0 +1,13 @@
namespace ReactorMaintenance.Simulation;
public sealed record RuleEffect
{
public ERuleEffectKind Kind { get; init; }
public GridPosition Position { get; init; } = new(0, 0);
public GridPosition? AccessPosition { get; init; }
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;
}

View File

@@ -0,0 +1,14 @@
namespace ReactorMaintenance.Simulation;
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;
}

View File

@@ -0,0 +1,18 @@
namespace ReactorMaintenance.Simulation;
public sealed record RulePredicate
{
public ERulePredicateKind Kind { get; init; }
public GridPosition Position { get; init; } = new(0, 0);
public int ReactorId { get; init; }
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 ENetworkValueKind NetworkValue { get; init; }
public ERemedyType Remedy { get; init; }
public EBand Band { get; init; }
public int InventoryCount { get; init; }
public bool BoolValue { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace ReactorMaintenance.Simulation;
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; }
}

View File

@@ -0,0 +1,11 @@
namespace ReactorMaintenance.Simulation;
public sealed record UndergroundCell
{
public EUndergroundState State { get; init; }
public float Amount { get; init; }
public float Intensity { get; init; }
public bool IsPresent => State != EUndergroundState.Absent;
public bool CarriesFlow => State is EUndergroundState.Intact or EUndergroundState.Leaking;
}

View File

@@ -0,0 +1,3 @@
namespace ReactorMaintenance.Simulation;
public sealed record ValidationIssue(string Message, GridPosition? Position = null);

View File

@@ -0,0 +1,8 @@
namespace ReactorMaintenance.Simulation;
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;
}

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

@@ -0,0 +1,36 @@
namespace ReactorMaintenance.Simulation;
public static class RobotStateExtensions
{
public static int Count(this RobotState robot, ERemedyType remedy)
{
return remedy switch {
ERemedyType.FuelNeutralizer => robot.FuelNeutralizers,
ERemedyType.CoolantNeutralizer => robot.CoolantNeutralizers,
ERemedyType.ElectricityNeutralizer => robot.ElectricityNeutralizers,
ERemedyType.HeatShield => robot.HeatShields,
_ => throw new ArgumentOutOfRangeException(nameof(remedy), remedy, "Unsupported remedy.")
};
}
public static RobotState Add(this RobotState robot, ERemedyType remedy, int amount)
{
return remedy switch {
ERemedyType.FuelNeutralizer => robot with { FuelNeutralizers = ClampInventory(robot.FuelNeutralizers + amount) },
ERemedyType.CoolantNeutralizer => robot with { CoolantNeutralizers = ClampInventory(robot.CoolantNeutralizers + amount) },
ERemedyType.ElectricityNeutralizer => robot with { ElectricityNeutralizers = ClampInventory(robot.ElectricityNeutralizers + amount) },
ERemedyType.HeatShield => robot with { HeatShields = ClampInventory(robot.HeatShields + amount) },
_ => throw new ArgumentOutOfRangeException(nameof(remedy), remedy, "Unsupported remedy.")
};
}
public static RobotState Spend(this RobotState robot, ERemedyType remedy)
{
return robot.Count(remedy) <= 0 ? robot : robot.Add(remedy, -1);
}
private static int ClampInventory(int value)
{
return Math.Clamp(value, 0, Balancing.Current.InventoryCapacityPerRemedy);
}
}

View File

@@ -0,0 +1,45 @@
namespace ReactorMaintenance.Simulation;
internal static class SimulationBands
{
public static EBand SurfaceBand(SurfaceState surface, ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => Fuel(surface.Fuel),
ECarrierType.Coolant => Coolant(surface.Coolant),
ECarrierType.Electricity => Electricity(surface.Electricity),
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
public static EBand NetworkBand(UndergroundCell underground, ECarrierType carrier, ENetworkValueKind valueKind)
{
var value = valueKind == ENetworkValueKind.Amount ? underground.Amount : underground.Intensity;
return carrier switch {
ECarrierType.Fuel => Fuel(value),
ECarrierType.Coolant => Coolant(value),
ECarrierType.Electricity => Electricity(value),
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
public static EBand Fuel(float value)
{
return Balancing.Current.Band(value, Balancing.Current.FuelCaution, Balancing.Current.FuelCritical);
}
public static EBand Coolant(float value)
{
return Balancing.Current.Band(value, Balancing.Current.CoolantCaution, Balancing.Current.CoolantCritical);
}
public static EBand Electricity(float value)
{
return Balancing.Current.Band(value, Balancing.Current.ElectricityCaution, Balancing.Current.ElectricityCritical);
}
public static EBand Heat(float value)
{
return Balancing.Current.Band(value, Balancing.Current.HeatCaution, Balancing.Current.HeatCritical);
}
}

View File

@@ -1,150 +1,78 @@
using ReactorMaintenance.Simulation.Effects; namespace ReactorMaintenance.Simulation;
using ReactorMaintenance.Simulation.Hazards;
namespace ReactorMaintenance.Simulation; public sealed class SimulationEngine
public sealed class SimulationEngine(IEnumerable<ISimulationEffect> effects, IEnumerable<IAreaSimulationEffect> areaEffects, IEnumerable<Hazard> hazards)
{ {
private sealed record ForecastKey(EFailureKind Kind, GridPosition? Position); public LevelState MoveRobot(LevelState level, GridPosition destination)
public SimulationEngine()
: this(
[new PipeLeakEffect(), new MachineEffect(), new FireAndElectricalHazardEffect(), new CellIntegrityEffect()],
[new SmokeSpreadEffect()],
[new PipeBurstHazard(), new IgnitionHazard(), new MeltdownHazard(), new StabilityCollapseHazard()])
{ {
return PlayerActionSystem.MoveRobot(level, destination, SpendAction);
} }
public LevelState AdvanceTurn(LevelState level) public LevelState InteractProp(LevelState level)
{ {
return AdvanceTurn(level, true); return PlayerActionSystem.InteractProp(level, SpendAction);
} }
public IReadOnlyList<Forecast> Forecast(LevelState level) public LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy)
{ {
var forecasts = new List<Forecast>(); return PlayerActionSystem.InteractLeak(level, carrier, useRemedy, SpendAction);
var seen = new HashSet<ForecastKey>();
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);
if (IsReactorReady(forecastLevel) || forecastLevel.Global.Lost || forecastLevel.Global.ReactorActivated)
return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray();
for (var step = Balancing.Current.TurnIncrement; step <= Balancing.Current.MaxForecastStepCount; step++)
{
forecastLevel = AdvanceTurn(forecastLevel, false);
AddHazardForecasts(forecasts, seen, forecastLevel, step);
AddReactorReadyForecast(forecasts, seen, forecastLevel, step);
if (forecastLevel.Global.Lost || IsReactorReady(forecastLevel) || forecastLevel.Global.ReactorActivated)
break;
} }
return forecasts.OrderBy(f => f.Turns).ThenBy(f => f.Message).ToArray(); public LevelState ApplyHeatShield(LevelState level)
{
return PlayerActionSystem.ApplyHeatShield(level, SpendAction);
} }
public LevelState ActivateReactor(LevelState level) public LevelState ActivateReactor(LevelState level)
{ {
if (!IsReactorReady(level)) return ReactorSystem.Activate(level);
return level with { Global = level.Global with { Status = "REACTOR NOT READY" } }; }
return level with { public LevelState EndTurn(LevelState level)
Global = level.Global with { {
ReactorActivated = true, return ResolveTurn(level with { Global = level.Global with { ActionsRemaining = 0 } });
Status = "REACTOR ONLINE" }
public LevelState AdvanceTurn(LevelState level)
{
return ResolveTurn(level);
}
public IReadOnlyList<Forecast> Forecast(LevelState level)
{
return ForecastSystem.Forecast(level, simulated => ResolveTurn(simulated, false));
}
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 LevelState ResolveTurn(LevelState level, bool refreshForecasts = true)
{
var report = m_Validator.Validate(level);
if (!report.IsValid)
return level with { Global = level.Global with { LevelState = ELevelState.Lost, Status = report.Errors[0].Message } };
var next = RuleEventSystem.Apply(level, ERuleEventPhase.StartOfSimulation);
next = NetworkPropagationSystem.Propagate(next);
next = ConsumerSystem.Resolve(next);
next = LeakSystem.Inject(next);
next = SurfaceInteractionSystem.Resolve(next);
next = RobotSafetySystem.Resolve(next);
next = ReactorSystem.DeriveState(next);
next = RuleEventSystem.Apply(next, ERuleEventPhase.EndOfTurn);
next = SurfaceInteractionSystem.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 AdvanceTurn(LevelState level, bool updateForecasts) private readonly LevelValidator m_Validator = new();
{
var cells = level.Cells.ToArray();
for (var y = Balancing.Current.FirstGridCoordinate; y < level.Height; y++)
{
for (var x = Balancing.Current.FirstGridCoordinate; x < level.Width; x++)
{
var position = new GridPosition(x, y);
var index = level.Index(position);
var cell = cells[index];
if (!cell.IsWalkable)
continue;
foreach (var effect in m_Effects)
cell = effect.Apply(cell);
cells[index] = cell with { Hazards = cell.Hazards.Clamp() };
}
}
foreach (var areaEffect in m_AreaEffects)
cells = areaEffect.Apply(level, cells);
var global = UpdateGlobal(level, cells);
var next = level with {
Cells = cells,
Global = global with { Turn = level.Global.Turn + Balancing.Current.TurnIncrement }
};
return updateForecasts ? next with { Forecasts = Forecast(next) } : next;
}
private void AddHazardForecasts(List<Forecast> forecasts, HashSet<ForecastKey> seen, LevelState level, int turns)
{
foreach (var hazard in m_Hazards)
{
foreach (var forecast in hazard.Predict(level, turns))
AddForecast(forecasts, seen, forecast);
}
}
private static void AddReactorReadyForecast(List<Forecast> forecasts, HashSet<ForecastKey> seen, LevelState level, int turns)
{
if (IsReactorReady(level))
AddForecast(forecasts, seen, new(EFailureKind.ReactorReady, null, turns, "REACTOR READY"));
}
private static void AddForecast(List<Forecast> forecasts, HashSet<ForecastKey> seen, Forecast forecast)
{
if (seen.Add(new(forecast.Kind, forecast.Position)))
forecasts.Add(forecast);
}
private static GlobalState UpdateGlobal(LevelState level, CellState[] cells)
{
var reactorHeat = cells.Where(c => c.Prop == ECellProp.Reactor).Select(c => c.Hazards.Heat).DefaultIfEmpty(level.Global.CoreHeat).Max();
var poweredGenerators = cells.Count(c => c is { Prop: ECellProp.Generator, Powered: true, Hazards.Fire: false });
var poweredPumps = cells.Count(c => c is { Prop: ECellProp.CoolingPump, Powered: true, Hazards.Fire: false });
var damagedCriticalCells = cells.Count(c => c.Prop is ECellProp.Reactor or ECellProp.Generator or ECellProp.CoolingPump && c.Hazards.Stability <= Balancing.Current.CriticalCellStabilityThreshold);
var stability = Rules.Clamp(level.Global.FacilityStability - damagedCriticalCells);
var lost = reactorHeat >= Balancing.Current.MeltdownCoreHeatThreshold || stability <= Balancing.Current.StabilityCollapseThreshold;
var status = lost ? reactorHeat >= Balancing.Current.MeltdownCoreHeatThreshold ? "CORE MELTDOWN" : "FACILITY STABILITY COLLAPSE" : "STABILIZE SYSTEMS";
var global = level.Global with {
CoreHeat = Rules.Clamp(reactorHeat - poweredPumps),
Power = Rules.Clamp(poweredGenerators * Balancing.Current.GeneratorPowerOutput),
Cooling = Rules.Clamp(poweredPumps * Balancing.Current.CoolingPumpOutput),
FacilityStability = stability,
Lost = lost,
Status = status
};
return IsReactorReady(level with { Cells = cells, Global = global }) ? global with { Status = "REACTOR READY" } : global;
}
private static bool IsReactorReady(LevelState level)
{
var hasReactor = level.Cells.Any(c => c.Prop == ECellProp.Reactor);
var hasStablePower = level.Global.Power >= Balancing.Current.ReactorReadyPowerThreshold || level.Cells.Any(c => c is { Prop: ECellProp.Generator, Powered: true, Hazards.Fire: false });
var hasCooling = level.Global.Cooling >= Balancing.Current.ReactorReadyCoolingThreshold || level.Cells.Any(c => c is { Prop: ECellProp.CoolingPump, Powered: true, Hazards.Fire: false });
var reactorStable = level.Global.CoreHeat < Balancing.Current.ReactorReadyCoreHeatThreshold;
return hasReactor && hasStablePower && hasCooling && reactorStable && !level.Global.Lost;
}
private readonly IReadOnlyList<IAreaSimulationEffect> m_AreaEffects = areaEffects.ToArray();
private readonly IReadOnlyList<ISimulationEffect> m_Effects = effects.ToArray();
private readonly IReadOnlyList<Hazard> m_Hazards = hazards.ToArray();
} }

View File

@@ -0,0 +1,24 @@
namespace ReactorMaintenance.Simulation;
internal static class SurfaceCarrierMath
{
public static SurfaceState AddCarrier(SurfaceState surface, ECarrierType carrier, float amount)
{
return carrier switch {
ECarrierType.Fuel => surface with { Fuel = surface.Fuel + amount },
ECarrierType.Coolant => surface with { Coolant = surface.Coolant + amount },
ECarrierType.Electricity => surface with { Electricity = surface.Electricity + amount },
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
public static SurfaceState RemoveCarrier(SurfaceState surface, ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => surface with { Fuel = 0, FuelBlockTurns = Balancing.Current.RemedyBlockTurns },
ECarrierType.Coolant => surface with { Coolant = 0, CoolantBlockTurns = Balancing.Current.RemedyBlockTurns },
ECarrierType.Electricity => surface with { Electricity = 0, ElectricityBlockTurns = Balancing.Current.RemedyBlockTurns },
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
}

View File

@@ -0,0 +1,11 @@
namespace ReactorMaintenance.Simulation;
public sealed record SurfaceInteractionEffect
{
public static SurfaceInteractionEffect Hold { get; } = new();
public ESurfaceInteractionVerb Verb { get; init; }
public ESurfaceQuantity Quantity { get; init; }
public float Amount { get; init; }
public float SecondaryAmount { get; init; }
}

View File

@@ -0,0 +1,28 @@
namespace ReactorMaintenance.Simulation;
public static class SurfaceStateExtensions
{
public static SurfaceState Clamp(this SurfaceState surface)
{
var balancing = Balancing.Current;
return surface with {
Fuel = balancing.ClampValue(surface.Fuel),
Coolant = balancing.ClampValue(surface.Coolant),
Electricity = balancing.ClampValue(surface.Electricity),
Heat = balancing.ClampValue(surface.Heat),
FuelBlockTurns = Math.Max(0, surface.FuelBlockTurns),
CoolantBlockTurns = Math.Max(0, surface.CoolantBlockTurns),
ElectricityBlockTurns = Math.Max(0, surface.ElectricityBlockTurns)
};
}
public static bool Blocks(this SurfaceState surface, ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => surface.FuelBlockTurns > 0,
ECarrierType.Coolant => surface.CoolantBlockTurns > 0,
ECarrierType.Electricity => surface.ElectricityBlockTurns > 0,
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
}

View File

@@ -0,0 +1,28 @@
namespace ReactorMaintenance.Simulation;
internal static class ConsumerSystem
{
public static LevelState Resolve(LevelState level)
{
var props = level.Props.ToArray();
foreach (var position in LevelTraversal.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 };
}
}

View File

@@ -0,0 +1,58 @@
namespace ReactorMaintenance.Simulation;
internal static class ForecastSystem
{
public static IReadOnlyList<Forecast> Forecast(LevelState level, Func<LevelState, LevelState> resolveTurn)
{
var forecasts = new List<Forecast>();
var simulated = CopyForForecast(level);
for (var turn = 0; turn <= Balancing.Current.ForecastHorizon; turn++)
{
AddForecasts(forecasts, simulated, turn);
if (simulated.Global.LevelState is ELevelState.Lost or ELevelState.Ready or ELevelState.Won)
break;
if (turn < Balancing.Current.ForecastHorizon)
simulated = resolveTurn(simulated);
}
return forecasts.DistinctBy(forecast => (forecast.Kind, forecast.Position, forecast.Message)).OrderBy(forecast => forecast.Turns).ThenBy(forecast => forecast.Message).ToArray();
}
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 LevelTraversal.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 (SimulationBands.Fuel(surface.Fuel) == EBand.Critical || SimulationBands.Coolant(surface.Coolant) == EBand.Critical || SimulationBands.Electricity(surface.Electricity) == EBand.Critical || SimulationBands.Heat(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 => RuleEventSystem.PredicateMatches(level, predicate))))
forecasts.Add(new(EForecastKind.RuleEvent, null, turn, ruleEvent.ForecastText));
}
}

View File

@@ -0,0 +1,24 @@
namespace ReactorMaintenance.Simulation;
internal static class LeakSystem
{
public static LevelState Inject(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] = SurfaceCarrierMath.AddCarrier(surface[accessIndex], leak.Carrier, amount);
}
return level with { Surface = surface.Select(cell => cell.Clamp()).ToArray() };
}
}

View File

@@ -0,0 +1,86 @@
namespace ReactorMaintenance.Simulation;
internal static class NetworkPropagationSystem
{
public static LevelState Propagate(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 static LevelState PropagateCarrier(LevelState level, ECarrierType carrier)
{
var layer = level.Layer(carrier).ToArray();
var sources = LevelTraversal.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();
var junctions = JunctionFlowAnalyzer.Analyze(level).Where(junction => junction.IsValid && junction.Carrier == carrier).ToDictionary(junction => junction.Position);
foreach (var source in sources)
ApplySourceFlow(level, layer, source, carrier, junctions);
return carrier switch {
ECarrierType.Fuel => level with { Fuel = layer },
ECarrierType.Coolant => level with { Coolant = layer },
ECarrierType.Electricity => level with { Electricity = layer },
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
private static void ApplySourceFlow(LevelState level, UndergroundCell[] layer, GridPosition source, ECarrierType carrier, IReadOnlyDictionary<GridPosition, JunctionFlow> junctions)
{
var open = new Queue<(GridPosition Position, int Distance, float AmountFactor, float IntensityFactor)>();
var best = new Dictionary<GridPosition, float>();
open.Enqueue((source, 0, 1, 1));
best[source] = 1;
while (open.Count > 0)
{
var current = open.Dequeue();
var amount = Balancing.Current.ClampValue((Balancing.Current.SourceAmount * current.AmountFactor) - (current.Distance * Balancing.Current.DistanceAmountFalloff));
var intensity = Balancing.Current.ClampValue((Balancing.Current.SourceIntensity * current.IntensityFactor) - (current.Distance * Balancing.Current.DistanceIntensityFalloff));
var index = level.Index(current.Position);
layer[index] = layer[index] with {
Amount = Math.Max(layer[index].Amount, amount),
Intensity = Math.Max(layer[index].Intensity, intensity)
};
foreach (var next in current.Position.Neighbors().Where(level.InBounds))
{
if (!level.GetUnderground(next, carrier).CarriesFlow)
continue;
var weights = BranchWeights(current.Position, next, junctions);
var amountFactor = current.AmountFactor * weights.Amount;
var intensityFactor = current.IntensityFactor * weights.Intensity;
if (amountFactor <= 0 || intensityFactor <= 0)
continue;
if (best.TryGetValue(next, out var oldBest) && oldBest >= amountFactor)
continue;
best[next] = amountFactor;
open.Enqueue((next, current.Distance + 1, amountFactor, intensityFactor));
}
}
}
private static (float Amount, float Intensity) BranchWeights(GridPosition from, GridPosition to, IReadOnlyDictionary<GridPosition, JunctionFlow> junctions)
{
if (!junctions.TryGetValue(from, out var junction))
return (1, 1);
var weight = junction.WeightFor(to);
return (weight, weight);
}
}

View File

@@ -0,0 +1,133 @@
namespace ReactorMaintenance.Simulation;
internal static class PlayerActionSystem
{
public static LevelState MoveRobot(LevelState level, GridPosition destination, Func<LevelState, LevelState> spendAction)
{
if (!CanSpendAction(level) || !level.IsFloor(destination) || level.Robot.Position.ManhattanDistance(destination) != 1)
return Refuse(level, "MOVE BLOCKED");
return spendAction(level with {
Robot = level.Robot with {
Position = destination,
HeatImmunitySteps = Math.Max(0, level.Robot.HeatImmunitySteps - 1)
}
});
}
public static LevelState InteractProp(LevelState level, Func<LevelState, LevelState> spendAction)
{
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.Junction => CycleJunctionMode(level, position, prop),
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 => ReactorSystem.Activate(level),
_ => level
};
return spendAction(next);
}
public static LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy, Func<LevelState, LevelState> spendAction)
{
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 static LevelState ApplyHeatShield(LevelState level, Func<LevelState, LevelState> spendAction)
{
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 }
});
}
private static LevelState ToggleProp(LevelState level, GridPosition position, PropState prop)
{
var switchState = prop.SwitchState == EPropSwitchState.Enabled ? EPropSwitchState.Disabled : EPropSwitchState.Enabled;
return level.SetProp(position, prop with { SwitchState = switchState });
}
private static LevelState ToggleDoor(LevelState level, GridPosition position)
{
var doors = level.Doors.ToArray();
var index = Array.FindIndex(doors, door => door.A == position || door.B == position);
if (index < 0)
return level;
doors[index] = doors[index] with { State = doors[index].State == EDoorState.Open ? EDoorState.Closed : EDoorState.Open };
return level with { Doors = doors };
}
private static LevelState PickUpRemedy(LevelState level, GridPosition position, PropState prop)
{
if (prop.Depleted || level.Robot.Count(prop.RemedyType) >= Balancing.Current.InventoryCapacityPerRemedy)
return level;
return level.SetProp(position, prop with { Depleted = true }) with { Robot = level.Robot.Add(prop.RemedyType, 1) };
}
private static LevelState RepairLeak(LevelState level, int leakIndex, LeakState leak)
{
var leaks = level.Leaks.ToArray();
leaks[leakIndex] = leak with { Repaired = true };
return level.SetUnderground(leak.UndergroundPosition, leak.Carrier, level.GetUnderground(leak.UndergroundPosition, leak.Carrier) with { State = EUndergroundState.Intact }) with { Leaks = leaks };
}
private static LevelState ApplyElementRemedy(LevelState level, LeakState leak)
{
var remedy = leak.Carrier switch {
ECarrierType.Fuel => ERemedyType.FuelNeutralizer,
ECarrierType.Coolant => ERemedyType.CoolantNeutralizer,
ECarrierType.Electricity => ERemedyType.ElectricityNeutralizer,
_ => throw new ArgumentOutOfRangeException(nameof(leak), leak.Carrier, "Unsupported leak carrier.")
};
if (level.Robot.Count(remedy) <= 0)
return Refuse(level, "NO REMEDY");
var surface = SurfaceCarrierMath.RemoveCarrier(level.GetSurface(leak.AccessPosition), leak.Carrier);
return level.SetSurface(leak.AccessPosition, surface) with { Robot = level.Robot.Spend(remedy) };
}
private static LevelState CycleJunctionMode(LevelState level, GridPosition position, PropState prop)
{
var flow = JunctionFlowAnalyzer.Analyze(level).FirstOrDefault(junction => junction.Position == position);
var outflowCount = flow?.OutgoingBranches.Count ?? 2;
var ratios = Balancing.Current.JunctionRatios(outflowCount);
if (ratios.Count == 0)
return level;
return level.SetProp(position, prop with { JunctionMode = (prop.JunctionMode + 1) % ratios.Count });
}
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 } };
}
}

View File

@@ -0,0 +1,78 @@
namespace ReactorMaintenance.Simulation;
internal static class ReactorSystem
{
public static LevelState Activate(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 static LevelState DeriveState(LevelState level)
{
if (level.Global.LevelState is ELevelState.Lost or ELevelState.Won)
return level;
var reactors = level.Reactors.Select(reactor => reactor with { Ready = IsReady(level, reactor) }).ToArray();
if (reactors.Any(reactor => reactor.Ready))
return level with { Reactors = reactors, Global = level.Global with { LevelState = ELevelState.Ready, Status = "REACTOR READY" } };
var maxHeat = level.Surface.Where((_, index) => level.Terrain[index] == ECellTerrain.Floor).Select(surface => surface.Heat).DefaultIfEmpty(0).Max();
if (maxHeat >= Balancing.Current.TerminalHeat)
return level with { Reactors = reactors, Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = "REACTOR HEAT TERMINAL" } };
var hasCritical = level.Surface.Any(surface => SimulationBands.Fuel(surface.Fuel) == EBand.Critical || SimulationBands.Coolant(surface.Coolant) == EBand.Critical || SimulationBands.Electricity(surface.Electricity) == EBand.Critical || SimulationBands.Heat(surface.Heat) == EBand.Critical);
var hasCaution = hasCritical || level.Props.Any(prop => prop.ServiceState is EConsumerServiceState.Starved or EConsumerServiceState.Disabled) || level.Leaks.Any(leak => !leak.Repaired);
var state = hasCritical ? ELevelState.Critical :
hasCaution ? ELevelState.Caution : ELevelState.Stable;
return level with { Reactors = reactors, Global = level.Global with { LevelState = state, Status = state.ToString().ToUpperInvariant() } };
}
public static bool MatchesReady(LevelState level, RulePredicate predicate)
{
return level.Reactors.Any(reactor => MatchesId(reactor, predicate.ReactorId) && reactor.Ready) == predicate.BoolValue;
}
public static bool MatchesWon(LevelState level, RulePredicate predicate)
{
var won = predicate.ReactorId > 0
? level.Reactors.Any(reactor => reactor.ReactorId == predicate.ReactorId && reactor.Activated)
: level.Global.LevelState == ELevelState.Won || level.Reactors.Any(reactor => reactor.Activated);
return won == predicate.BoolValue;
}
private static bool IsReady(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 static bool MatchesId(ReactorBinding reactor, int reactorId)
{
return reactorId <= 0 || reactor.ReactorId == reactorId;
}
private static LevelState Refuse(LevelState level, string message)
{
return level with { Global = level.Global with { Status = message } };
}
}

View File

@@ -0,0 +1,14 @@
namespace ReactorMaintenance.Simulation;
internal static class RobotSafetySystem
{
public static LevelState Resolve(LevelState level)
{
var surface = level.GetSurface(level.Robot.Position);
var unsafeElement = surface.Fuel >= Balancing.Current.RobotFuelSafetyThreshold || surface.Coolant >= Balancing.Current.RobotCoolantSafetyThreshold || surface.Electricity >= Balancing.Current.RobotElectricitySafetyThreshold;
var unsafeHeat = surface.Heat >= Balancing.Current.RobotHeatSafetyThreshold && level.Robot.HeatImmunitySteps <= 0;
return unsafeElement || unsafeHeat
? level with { Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = "ROBOT LOST" } }
: level;
}
}

View File

@@ -0,0 +1,73 @@
namespace ReactorMaintenance.Simulation;
internal static class RuleEventSystem
{
public static LevelState Apply(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 = ApplyEffect(next, effect);
ruleEvents[item.Index] = item.Event with { Triggered = true };
}
return next with { RuleEvents = ruleEvents };
}
public 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.ReactorReadyIs => ReactorSystem.MatchesReady(level, predicate),
ERulePredicateKind.ReactorLostIs => level.Global.LevelState == ELevelState.Lost == predicate.BoolValue,
ERulePredicateKind.ReactorWonIs => ReactorSystem.MatchesWon(level, predicate),
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.NetworkBandAt => level.InBounds(predicate.Position) && SimulationBands.NetworkBand(level.GetUnderground(predicate.Position, predicate.Carrier), predicate.Carrier, predicate.NetworkValue) >= predicate.Band,
ERulePredicateKind.SurfaceBandAt => level.InBounds(predicate.Position) && SimulationBands.SurfaceBand(level.GetSurface(predicate.Position), predicate.Carrier) >= predicate.Band,
ERulePredicateKind.RobotAt => level.Robot.Position == predicate.Position,
ERulePredicateKind.RobotInventoryAtLeast => level.Robot.Count(predicate.Remedy) >= predicate.InventoryCount,
ERulePredicateKind.AllSeeingEyeUnlocked => level.Global.AllSeeingEyeUnlocked == predicate.BoolValue,
_ => false
};
}
private static LevelState ApplyEffect(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, SurfaceCarrierMath.AddCarrier(level.GetSurface(effect.Position), effect.Carrier, effect.Amount)),
ERuleEffectKind.RemoveSurfaceHazard => level.SetSurface(effect.Position, SurfaceCarrierMath.AddCarrier(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.RemoveHeat => 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.RemoveInventory => 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.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] };
}
}

View File

@@ -0,0 +1,135 @@
namespace ReactorMaintenance.Simulation;
internal static class SurfaceInteractionSystem
{
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; }
}
public static LevelState Resolve(LevelState level)
{
var deltas = Enumerable.Range(0, level.Width * level.Height).Select(_ => new SurfaceDelta()).ToArray();
foreach (var position in LevelTraversal.AllPositions(level).Where(level.IsFloor))
ApplySameCellInteractions(level, position, deltas);
foreach (var position in LevelTraversal.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 };
}
public static 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 void ApplySameCellInteractions(LevelState level, GridPosition position, SurfaceDelta[] deltas)
{
var surface = level.GetSurface(position);
ApplyPair(level, position, ECarrierType.Fuel, SimulationBands.Fuel(surface.Fuel), ECarrierType.Electricity, SimulationBands.Electricity(surface.Electricity), deltas);
ApplyPair(level, position, ECarrierType.Fuel, SimulationBands.Fuel(surface.Fuel), null, SimulationBands.Heat(surface.Heat), deltas);
ApplyPair(level, position, ECarrierType.Coolant, SimulationBands.Coolant(surface.Coolant), ECarrierType.Electricity, SimulationBands.Electricity(surface.Electricity), deltas);
ApplyPair(level, position, ECarrierType.Coolant, SimulationBands.Coolant(surface.Coolant), null, SimulationBands.Heat(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, Balancing.Current.FlowInteraction(ESurfaceQuantity.Fuel), deltas);
FlowBetween(level, a, b, surfaceA.Coolant, surfaceB.Coolant, Balancing.Current.FlowInteraction(ESurfaceQuantity.Coolant), deltas);
FlowBetween(level, a, b, surfaceA.Electricity, surfaceB.Electricity, Balancing.Current.FlowInteraction(ESurfaceQuantity.Electricity), deltas);
FlowBetween(level, a, b, surfaceA.Heat, surfaceB.Heat, Balancing.Current.FlowInteraction(ESurfaceQuantity.Heat), deltas);
}
private static void ApplyPair(LevelState level, GridPosition position, ECarrierType? rowCarrier, EBand rowBand, ECarrierType? colCarrier, EBand colBand, SurfaceDelta[] deltas)
{
ApplyEffect(level, position, Balancing.Current.SameCellInteraction(rowCarrier, rowBand, colCarrier, colBand), deltas);
}
private static void ApplyEffect(LevelState level, GridPosition position, SurfaceInteractionEffect effect, SurfaceDelta[] deltas)
{
var index = level.Index(position);
switch (effect.Verb)
{
case ESurfaceInteractionVerb.Warm:
deltas[index].Heat += effect.Amount;
break;
case ESurfaceInteractionVerb.Quench:
deltas[index].Heat -= effect.Amount;
break;
case ESurfaceInteractionVerb.Short:
deltas[index].Heat += effect.Amount;
deltas[index].Electricity -= effect.SecondaryAmount;
break;
case ESurfaceInteractionVerb.Ignite:
deltas[index].Heat += effect.Amount;
deltas[index].Fuel -= effect.SecondaryAmount;
break;
}
}
private static void FlowBetween(LevelState level, GridPosition a, GridPosition b, float valueA, float valueB, SurfaceInteractionEffect effect, SurfaceDelta[] deltas)
{
var difference = valueA - valueB;
if (Math.Abs(difference) < 0.01f)
return;
var amount = difference * effect.Amount;
var indexA = level.Index(a);
var indexB = level.Index(b);
switch (effect.Quantity)
{
case ESurfaceQuantity.Fuel:
deltas[indexA].Fuel -= amount;
deltas[indexB].Fuel += amount;
break;
case ESurfaceQuantity.Coolant:
deltas[indexA].Coolant -= amount;
deltas[indexB].Coolant += amount;
break;
case ESurfaceQuantity.Electricity:
deltas[indexA].Electricity -= amount;
deltas[indexB].Electricity += amount;
break;
case ESurfaceQuantity.Heat:
deltas[indexA].Heat -= amount;
deltas[indexB].Heat += amount;
break;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Some files were not shown because too many files have changed in this diff Show More