12 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
77 changed files with 2748 additions and 1676 deletions

View File

@@ -20,9 +20,10 @@ insert_final_newline = false
# Organize usings
dotnet_separate_import_directive_groups = 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_field = 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_members_in_anonymous_types = 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
# 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.style = pascal_case
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.required_modifiers = const
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_readonly_symbols.applicable_accessibilities = private
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_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.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.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_kinds = namespace,class,struct,enum,delegate
dotnet_naming_symbols.types_and_namespaces_symbols.applicable_kinds = namespace, class, struct, enum, delegate
# ReSharper inspection severities
resharper_arrange_accessor_owner_body_highlighting = suggestion

View File

@@ -1,4 +1,4 @@
# 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 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

@@ -1,12 +1,20 @@
# Reactor Maintenance
# Reactor Maintenance
C# WinUI 3 + Win2D level editor for the deterministic grid simulation described in `docs/design.md`.
## Projects
- `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 authoring terrain, underground fuel/coolant/electricity networks, props, leaks, doors, surface hazards, robot start, loading/saving levels, ending turns, interacting with props, and activating a ready reactor.
- `tests/ReactorMaintenance.Simulation.Tests`: unit tests for deterministic simulation behavior.
- `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, 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

View File

@@ -1,11 +1,11 @@
<Solution>
<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">
<Platform Project="x86" />
<Platform Project="x86"/>
</Project>
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj" />
<Project Path="tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj"/>
</Folder>
</Solution>

View File

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

View File

@@ -1,4 +1,4 @@
# Reactor Maintenance Design
# Reactor Maintenance Design
## Concept
@@ -114,8 +114,7 @@ Surface prop categories:
- flow prop
- consumer prop
- T-junction prop
- cross-junction prop
- junction prop
- door prop
- all-seeing-eye terminal prop
- remedy supply prop
@@ -155,24 +154,10 @@ The level is invalid if any binding is missing, out of bounds, or points to the
A junction prop must be on a floor cell whose coordinate has exactly one underground carrier. That carrier is the regulated network.
T-junction modes:
The engine infers incoming and outgoing branch directions from valid network topology and enabled source paths. A valid junction has one incoming branch and either two or three outgoing branches. Ambiguous junction flow is invalid. Ratio numbers are balance-defined weights that divide carrier amount and pressure or voltage. A zero-weight branch receives no intentional outflow.
- `0/4`
- `1/3`
- `2/2`
- `3/1`
- `4/0`
Cross-junction modes:
- `0/3/3`
- `3/0/3`
- `3/3/0`
- `2/2/2`
The engine infers incoming and outgoing branch directions from valid network topology and enabled source paths. Ambiguous junction flow is invalid. Ratio numbers are weights that divide carrier amount and pressure or voltage by balance-defined math. A zero-weight branch receives no intentional outflow.
The gameplay UI exposes ratio modes.
The gameplay UI exposes a single junction tool and cycles through balance-defined ratio presets for the inferred outgoing branch count.
Editor commands use a verb plus parameters, so carrier-specific choices such as fuel flow or coolant flow are UI presets over one `Flow` command instead of separate simulation concepts.
### Doors
@@ -284,33 +269,28 @@ Balance thresholds project numeric values into safe, caution, and critical bands
The pair table maps projected bands to deterministic effects:
- `Hold`: no direct change.
- `FuelFlow`: equalize leaked fuel.
- `CoolFlow`: equalize leaked coolant.
- `ChargeFlow`: equalize leaked electricity.
- `HeatFlow`: transfer heat.
- `HeatFlow2`: stronger heat transfer.
- `Warm1`: small heat increase from fuel exposure.
- `Warm2`: larger heat increase from fuel exposure.
- `Quench1`: small heat reduction from coolant exposure.
- `Quench2`: larger heat reduction from coolant exposure.
- `Short1`: electricity and coolant interaction causing heat and discharge.
- `Short2`: stronger short.
- `Ignite1`: fuel interaction with electricity or high heat.
- `Ignite2`: stronger ignition.
The pair table maps projected bands to parameterized verbs:
- `Hold`: no direct change.
- `Flow(amount)`: equalize a surface quantity by a balance-defined transfer amount.
- `Warm(amount)`: increase heat by a balance-defined amount.
- `Quench(amount)`: reduce heat by a balance-defined amount.
- `Short(heat, discharge)`: add heat and discharge electricity by balance-defined amounts.
- `Ignite(heat, fuel)`: add heat and consume fuel by balance-defined amounts.
| Row\Col | FuelSafe | FuelCaution | FuelCritical | CoolantSafe | CoolantCaution | CoolantCritical | ElectricitySafe | ElectricityCaution | ElectricityCritical | HeatSafe | HeatCaution | HeatCritical |
| ------- | -------- | ----------- | ------------ | ----------- | -------------- | --------------- | --------------- | ------------------ | ------------------- | -------- | ----------- | ------------ |
| FuelSafe | Hold | FuelFlow | FuelFlow | Hold | Hold | Hold | Hold | Warm1 | Warm2 | Hold | Warm1 | Warm2 |
| FuelCaution | | Hold | FuelFlow | Hold | Hold | Hold | Warm1 | Ignite1 | Ignite2 | Warm1 | Ignite1 | Ignite2 |
| FuelCritical | | | Hold | Hold | Hold | Hold | Warm2 | Ignite2 | Ignite2 | Warm2 | Ignite2 | Ignite2 |
| CoolantSafe | | | | Hold | CoolFlow | CoolFlow | Hold | Short1 | Short2 | Hold | Quench1 | Quench1 |
| CoolantCaution | | | | | Hold | CoolFlow | Short1 | Short1 | Short2 | Hold | Quench1 | Quench2 |
| CoolantCritical | | | | | | Hold | Short2 | Short2 | Short2 | Hold | Quench2 | Quench2 |
| ElectricitySafe | | | | | | | Hold | ChargeFlow | ChargeFlow | Hold | Hold | Hold |
| ElectricityCaution | | | | | | | | Hold | ChargeFlow | Hold | Hold | Hold |
| FuelSafe | Hold | Flow | Flow | Hold | Hold | Hold | Hold | Warm | Ignite | Hold | Warm | Ignite |
| FuelCaution | | Hold | Flow | Hold | Hold | Hold | Warm | Ignite | Ignite | Warm | Ignite | Ignite |
| FuelCritical | | | Hold | Hold | Hold | Hold | Ignite | Ignite | Ignite | Ignite | Ignite | Ignite |
| CoolantSafe | | | | Hold | Flow | Flow | Hold | Short | Short | Hold | Quench | Quench |
| CoolantCaution | | | | | Hold | Flow | Short | Short | Short | Hold | Quench | Quench |
| CoolantCritical | | | | | | Hold | Short | Short | Short | Hold | Quench | Quench |
| ElectricitySafe | | | | | | | Hold | Flow | Flow | Hold | Hold | Hold |
| ElectricityCaution | | | | | | | | Hold | Flow | Hold | Hold | Hold |
| ElectricityCritical | | | | | | | | | Hold | Hold | Hold | Hold |
| HeatSafe | | | | | | | | | | Hold | HeatFlow | HeatFlow2 |
| HeatCaution | | | | | | | | | | | Hold | HeatFlow |
| HeatSafe | | | | | | | | | | Hold | Flow | Flow |
| HeatCaution | | | | | | | | | | | Hold | Flow |
| HeatCritical | | | | | | | | | | | | Hold |
Design rules:
@@ -421,12 +401,12 @@ The editor authors:
- surface terrain
- underground fuel, coolant, and electricity cells
- flow props and consumer props
- reactor controls and reactor consumer bindings
- junction props and ratio modes
- door props and door edges
- reactor controls and explicit reactor consumer bindings
- junction props and balance-defined ratio mode index
- door props and explicit door edges
- all-seeing-eye terminals
- remedy supplies
- floor leaks and electricity wall leaks
- floor leaks and electricity wall leaks with authored access faces
- initial surface hazards and heat
- robot start position
- rule events

View File

@@ -1,11 +1,9 @@
using ReactorMaintenance.Simulation.Difficulties;
using ReactorMaintenance.Simulation.Difficulties;
namespace ReactorMaintenance.Simulation;
public abstract class Balancing
{
public static Balancing Current { get; set; } = new NormalBalancing();
public float ClampValue(float value)
{
return Math.Clamp(value, MinValue, MaxValue);
@@ -19,6 +17,96 @@ public abstract class Balancing
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 abstract int DefaultLevelWidth { get; }
public abstract int DefaultLevelHeight { get; }
public abstract int MinimumLevelSize { get; }
@@ -47,25 +135,26 @@ public abstract class Balancing
public abstract float SourceIntensity { get; }
public abstract float DistanceAmountFalloff { get; }
public abstract float DistanceIntensityFalloff { get; }
public abstract IReadOnlyList<JunctionRatioPreset> TwoOutflowJunctionRatios { get; }
public abstract IReadOnlyList<JunctionRatioPreset> ThreeOutflowJunctionRatios { get; }
public abstract float ConsumerRequiredAmount { get; }
public abstract float ConsumerRequiredIntensity { get; }
public abstract float LeakBaseAmount { get; }
public abstract float LeakAmountScale { get; }
public abstract float LeakIntensityScale { get; }
public abstract float FlowTransferRatio { get; }
public abstract float StrongFlowTransferRatio { get; }
public abstract float Warm1Amount { get; }
public abstract float Warm2Amount { get; }
public abstract float Quench1Amount { get; }
public abstract float Quench2Amount { get; }
public abstract float Short1Heat { get; }
public abstract float Short1Discharge { get; }
public abstract float Short2Heat { get; }
public abstract float Short2Discharge { get; }
public abstract float Ignite1Heat { get; }
public abstract float Ignite1FuelConsumption { get; }
public abstract float Ignite2Heat { get; }
public abstract float Ignite2FuelConsumption { get; }
public abstract float WarmCautionAmount { get; }
public abstract float WarmCriticalAmount { get; }
public abstract float QuenchCautionAmount { get; }
public abstract float QuenchCriticalAmount { get; }
public abstract float ShortCautionHeat { get; }
public abstract float ShortCautionDischarge { get; }
public abstract float ShortCriticalHeat { get; }
public abstract float ShortCriticalDischarge { get; }
public abstract float IgniteCautionHeat { get; }
public abstract float IgniteCautionFuelConsumption { get; }
public abstract float IgniteCriticalHeat { get; }
public abstract float IgniteCriticalFuelConsumption { get; }
public abstract int RemedyBlockTurns { get; }
public abstract int HeatShieldSteps { get; }
public abstract int InventoryCapacityPerRemedy { get; }

View File

@@ -1,4 +1,4 @@
namespace ReactorMaintenance.Simulation.Difficulties;
namespace ReactorMaintenance.Simulation.Difficulties;
public class NormalBalancing : Balancing
{
@@ -30,25 +30,40 @@ public class NormalBalancing : Balancing
public override float SourceIntensity => 8;
public override float DistanceAmountFalloff => 0.5f;
public override float DistanceIntensityFalloff => 0.4f;
public override IReadOnlyList<JunctionRatioPreset> TwoOutflowJunctionRatios { get; } = [
new("0/4", [0, 1]),
new("1/3", [0.25f, 0.75f]),
new("2/2", [0.5f, 0.5f]),
new("3/1", [0.75f, 0.25f]),
new("4/0", [1, 0])
];
public override IReadOnlyList<JunctionRatioPreset> ThreeOutflowJunctionRatios { get; } = [
new("0/3/3", [0, 0.5f, 0.5f]),
new("3/0/3", [0.5f, 0, 0.5f]),
new("3/3/0", [0.5f, 0.5f, 0]),
new("2/2/2", [1f / 3f, 1f / 3f, 1f / 3f])
];
public override float ConsumerRequiredAmount => 2.5f;
public override float ConsumerRequiredIntensity => 2.5f;
public override float LeakBaseAmount => 0.5f;
public override float LeakAmountScale => 0.15f;
public override float LeakIntensityScale => 0.1f;
public override float FlowTransferRatio => 0.05f;
public override float StrongFlowTransferRatio => 0.1f;
public override float Warm1Amount => 0.5f;
public override float Warm2Amount => 1.0f;
public override float Quench1Amount => 0.6f;
public override float Quench2Amount => 1.2f;
public override float Short1Heat => 0.8f;
public override float Short1Discharge => 0.8f;
public override float Short2Heat => 1.6f;
public override float Short2Discharge => 1.5f;
public override float Ignite1Heat => 1.2f;
public override float Ignite1FuelConsumption => 0.4f;
public override float Ignite2Heat => 2.4f;
public override float Ignite2FuelConsumption => 0.8f;
public override float WarmCautionAmount => 0.5f;
public override float WarmCriticalAmount => 1.0f;
public override float QuenchCautionAmount => 0.6f;
public override float QuenchCriticalAmount => 1.2f;
public override float ShortCautionHeat => 0.8f;
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

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

@@ -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,93 +1,92 @@
namespace ReactorMaintenance.Simulation;
public enum EEditorTool
{
Cursor,
Floor,
Wall,
FuelUnderground,
CoolantUnderground,
ElectricityUnderground,
FuelFlow,
CoolantFlow,
ElectricityFlow,
FuelConsumer,
CoolantConsumer,
ElectricityConsumer,
TJunction,
CrossJunction,
Door,
AllSeeingEyeTerminal,
FuelRemedySupply,
CoolantRemedySupply,
ElectricityRemedySupply,
HeatRemedySupply,
ReactorControl,
FuelLeak,
CoolantLeak,
ElectricityLeak,
FuelHazard,
CoolantHazard,
ElectricityHazard,
Heat,
Robot
}
namespace ReactorMaintenance.Simulation;
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))
return level;
return tool switch {
return command.Tool switch {
EEditorTool.Cursor => level,
EEditorTool.Floor => level.SetTerrain(position, ECellTerrain.Floor),
EEditorTool.Wall => level.SetTerrain(position, ECellTerrain.Wall),
EEditorTool.FuelUnderground => SetUnderground(level, position, ECarrierType.Fuel),
EEditorTool.CoolantUnderground => SetUnderground(level, position, ECarrierType.Coolant),
EEditorTool.ElectricityUnderground => SetUnderground(level, position, ECarrierType.Electricity),
EEditorTool.FuelFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Fuel),
EEditorTool.CoolantFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Coolant),
EEditorTool.ElectricityFlow => SetCarrierProp(level, position, EPropType.Flow, ECarrierType.Electricity),
EEditorTool.FuelConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Fuel),
EEditorTool.CoolantConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Coolant),
EEditorTool.ElectricityConsumer => SetCarrierProp(level, position, EPropType.Consumer, ECarrierType.Electricity),
EEditorTool.TJunction => SetFloorProp(level, position, new() { Type = EPropType.TJunction }),
EEditorTool.CrossJunction => SetFloorProp(level, position, new() { Type = EPropType.CrossJunction }),
EEditorTool.Door => SetDoor(level, position),
EEditorTool.Underground => SetUnderground(level, position, command.Carrier),
EEditorTool.Flow => SetCarrierProp(level, position, EPropType.Flow, command.Carrier),
EEditorTool.Consumer => SetCarrierProp(level, position, EPropType.Consumer, command.Carrier),
EEditorTool.Junction => SetFloorProp(level, position, new() { Type = EPropType.Junction }),
EEditorTool.Door => SetFloorProp(level, position, new() { Type = EPropType.Door }),
EEditorTool.AllSeeingEyeTerminal => SetFloorProp(level, position, new() { Type = EPropType.AllSeeingEyeTerminal }),
EEditorTool.FuelRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.FuelNeutralizer }),
EEditorTool.CoolantRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.CoolantNeutralizer }),
EEditorTool.ElectricityRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.ElectricityNeutralizer }),
EEditorTool.HeatRemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.HeatShield }),
EEditorTool.RemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = command.RemedyType }),
EEditorTool.ReactorControl => SetReactorControl(level, position),
EEditorTool.FuelLeak => SetLeak(level, position, ECarrierType.Fuel),
EEditorTool.CoolantLeak => SetLeak(level, position, ECarrierType.Coolant),
EEditorTool.ElectricityLeak => SetLeak(level, position, ECarrierType.Electricity),
EEditorTool.FuelHazard => level.SetSurface(position, level.GetSurface(position) with { Fuel = level.GetSurface(position).Fuel + 1 }),
EEditorTool.CoolantHazard => level.SetSurface(position, level.GetSurface(position) with { Coolant = level.GetSurface(position).Coolant + 1 }),
EEditorTool.ElectricityHazard => level.SetSurface(position, level.GetSurface(position) with { Electricity = level.GetSurface(position).Electricity + 1 }),
EEditorTool.Leak => SetLeak(level, position, command.Carrier),
EEditorTool.SurfaceHazard => AddSurfaceHazard(level, position, command.Carrier),
EEditorTool.Heat => level.SetSurface(position, level.GetSurface(position) with { Heat = level.GetSurface(position).Heat + 1 }),
EEditorTool.Robot => level.IsFloor(position) ? level with { Robot = level.Robot with { Position = position } } : level,
_ => level
};
}
public static LevelState BindFirstReactorToConsumers(LevelState level, GridPosition fuelConsumer, GridPosition coolantConsumer, GridPosition electricityConsumer)
public static LevelState SetDoorEdge(LevelState level, GridPosition a, GridPosition b)
{
if (level.Reactors.Count == 0)
if (!level.IsFloor(a) || !level.IsFloor(b) || a.ManhattanDistance(b) != 1)
return level;
var reactors = level.Reactors.ToArray();
reactors[0] = reactors[0] with {
FuelConsumerPosition = fuelConsumer,
CoolantConsumerPosition = coolantConsumer,
ElectricityConsumerPosition = electricityConsumer
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 });
@@ -98,25 +97,22 @@ public static class LevelEditor
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 SetDoor(LevelState level, GridPosition position)
{
if (!level.IsFloor(position))
return level;
var neighbor = position.Neighbors().FirstOrDefault(level.IsFloor);
if (neighbor is null)
return SetFloorProp(level, position, new() { Type = EPropType.Door });
return SetFloorProp(level, position, new() { Type = EPropType.Door }) with {
Doors = [.. level.Doors, new DoorState { A = position, B = neighbor }]
};
}
private static LevelState SetReactorControl(LevelState level, GridPosition position)
{
if (!level.IsFloor(position))
@@ -127,7 +123,7 @@ public static class LevelEditor
return levelWithProp with {
Reactors = [
.. level.Reactors,
new ReactorBinding {
new() {
ReactorId = id,
ControlPosition = position,
FuelConsumerPosition = position,
@@ -143,23 +139,30 @@ public static class LevelEditor
if (!level.InBounds(position))
return level;
var accessPosition = carrier == ECarrierType.Electricity && level.GetTerrain(position) == ECellTerrain.Wall
? position.Neighbors().FirstOrDefault(level.IsFloor)
: position;
if (accessPosition is null || !level.IsFloor(accessPosition))
return level;
var next = level.SetUnderground(position, carrier, new() { State = EUndergroundState.Leaking });
return next with {
Leaks = [
.. next.Leaks,
new LeakState {
Carrier = carrier,
UndergroundPosition = position,
AccessPosition = accessPosition
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,7 +5,11 @@ namespace ReactorMaintenance.Simulation;
public static class LevelSerializer
{
private const int c_CurrentVersion = 2;
private sealed record LevelFile
{
public int Version { get; init; }
public LevelState? Level { get; init; }
}
public static string Serialize(LevelState level)
{
@@ -29,14 +33,10 @@ public static class LevelSerializer
return level;
}
private const int c_CurrentVersion = 2;
private static readonly JsonSerializerOptions s_Options = new() {
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
private sealed record LevelFile
{
public int Version { get; init; }
public LevelState? Level { get; init; }
}
}

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

@@ -1,4 +1,4 @@
namespace ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Simulation;
public sealed class LevelValidator
{
@@ -113,32 +113,113 @@ public sealed class LevelValidator
private static void ValidateJunctions(LevelState level, List<ValidationIssue> errors)
{
for (var y = 0; y < level.Height; y++)
{
for (var x = 0; x < level.Width; x++)
{
var position = new GridPosition(x, y);
var prop = level.GetProp(position);
if (prop.Type is not (EPropType.TJunction or EPropType.CrossJunction))
continue;
var carrierCount = Enum.GetValues<ECarrierType>().Count(carrier => level.GetUnderground(position, carrier).IsPresent);
if (carrierCount != 1)
errors.Add(new("Junction must regulate exactly one underground carrier.", position));
}
}
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)
{
if (!level.InBounds(effect.Position) && effect.Kind != ERuleEffectKind.EmitWarning && effect.Kind != ERuleEffectKind.MarkTerminalLoss && effect.Kind != ERuleEffectKind.AddInventory)
errors.Add(new("Rule effect target is out of bounds.", effect.Position));
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)

View File

@@ -1,534 +0,0 @@
namespace ReactorMaintenance.Simulation;
public enum ECellTerrain
{
Floor,
Wall
}
public enum ECarrierType
{
Fuel,
Coolant,
Electricity
}
public enum EUndergroundState
{
Absent,
Intact,
Leaking
}
public enum EPropType
{
None,
Flow,
Consumer,
TJunction,
CrossJunction,
Door,
AllSeeingEyeTerminal,
RemedySupply,
ReactorControl
}
public enum EPropSwitchState
{
Disabled,
Enabled
}
public enum EConsumerServiceState
{
Unknown,
Disabled,
Starved,
Supplied,
Producing
}
public enum ETJunctionMode
{
ZeroFour,
OneThree,
TwoTwo,
ThreeOne,
FourZero
}
public enum ECrossJunctionMode
{
ZeroThreeThree,
ThreeZeroThree,
ThreeThreeZero,
TwoTwoTwo
}
public enum EDoorState
{
Open,
Closed
}
public enum ERemedyType
{
FuelNeutralizer,
CoolantNeutralizer,
ElectricityNeutralizer,
HeatShield
}
public enum ELevelState
{
Stable,
Caution,
Critical,
Ready,
Lost,
Won
}
public enum EForecastKind
{
TerminalLoss,
ReactorReady,
ConsumerStarved,
HazardGrowth,
RuleEvent
}
public enum ERuleEventPhase
{
StartOfSimulation,
EndOfTurn
}
public enum ERulePredicateKind
{
TurnAtLeast,
LevelStateIs,
PropStateAt,
ConsumerStateAt,
SurfaceBandAt,
RobotAt,
AllSeeingEyeUnlocked
}
public enum ERuleEffectKind
{
StartLeak,
WorsenLeak,
RepairNetworkCell,
DisableNetworkCell,
SetPropEnabled,
AddSurfaceHazard,
AddHeat,
AddInventory,
MarkTerminalLoss,
EmitWarning
}
public enum EBand
{
Safe,
Caution,
Critical
}
public enum EPairEffect
{
Hold,
FuelFlow,
CoolFlow,
ChargeFlow,
HeatFlow,
HeatFlow2,
Warm1,
Warm2,
Quench1,
Quench2,
Short1,
Short2,
Ignite1,
Ignite2
}
public sealed record GridPosition(int X, int Y)
{
public IEnumerable<GridPosition> Neighbors()
{
yield return new(X, Y - 1);
yield return new(X + 1, Y);
yield return new(X, Y + 1);
yield return new(X - 1, Y);
}
public int ManhattanDistance(GridPosition other)
{
return Math.Abs(X - other.X) + Math.Abs(Y - other.Y);
}
}
public sealed record UndergroundCell
{
public EUndergroundState State { get; init; }
public float Amount { get; init; }
public float Intensity { get; init; }
public bool IsPresent => State != EUndergroundState.Absent;
public bool CarriesFlow => State is EUndergroundState.Intact or EUndergroundState.Leaking;
}
public sealed record SurfaceState
{
public float Fuel { get; init; }
public float Coolant { get; init; }
public float Electricity { get; init; }
public float Heat { get; init; }
public int FuelBlockTurns { get; init; }
public int CoolantBlockTurns { get; init; }
public int ElectricityBlockTurns { get; init; }
public SurfaceState Clamp()
{
var balancing = Balancing.Current;
return this with {
Fuel = balancing.ClampValue(Fuel),
Coolant = balancing.ClampValue(Coolant),
Electricity = balancing.ClampValue(Electricity),
Heat = balancing.ClampValue(Heat),
FuelBlockTurns = Math.Max(0, FuelBlockTurns),
CoolantBlockTurns = Math.Max(0, CoolantBlockTurns),
ElectricityBlockTurns = Math.Max(0, ElectricityBlockTurns)
};
}
public bool Blocks(ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => FuelBlockTurns > 0,
ECarrierType.Coolant => CoolantBlockTurns > 0,
ECarrierType.Electricity => ElectricityBlockTurns > 0,
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
}
public sealed record PropState
{
public EPropType Type { get; init; }
public ECarrierType Carrier { get; init; }
public EPropSwitchState SwitchState { get; init; } = EPropSwitchState.Enabled;
public EConsumerServiceState ServiceState { get; init; } = EConsumerServiceState.Unknown;
public ETJunctionMode TJunctionMode { get; init; } = ETJunctionMode.TwoTwo;
public ECrossJunctionMode CrossJunctionMode { get; init; } = ECrossJunctionMode.TwoTwoTwo;
public ERemedyType RemedyType { get; init; }
public bool Depleted { get; init; }
public int ReactorId { get; init; }
public bool IsEnabled => SwitchState == EPropSwitchState.Enabled;
}
public sealed record DoorState
{
public GridPosition A { get; init; } = new(0, 0);
public GridPosition B { get; init; } = new(0, 0);
public EDoorState State { get; init; } = EDoorState.Closed;
}
public sealed record LeakState
{
public ECarrierType Carrier { get; init; }
public GridPosition UndergroundPosition { get; init; } = new(0, 0);
public GridPosition AccessPosition { get; init; } = new(0, 0);
public bool Repaired { get; init; }
}
public sealed record ReactorBinding
{
public int ReactorId { get; init; }
public GridPosition ControlPosition { get; init; } = new(0, 0);
public GridPosition FuelConsumerPosition { get; init; } = new(0, 0);
public GridPosition CoolantConsumerPosition { get; init; } = new(0, 0);
public GridPosition ElectricityConsumerPosition { get; init; } = new(0, 0);
public bool Ready { get; init; }
public bool Activated { get; init; }
}
public sealed record RobotState
{
public GridPosition Position { get; init; } = new(1, 1);
public int FuelNeutralizers { get; init; }
public int CoolantNeutralizers { get; init; }
public int ElectricityNeutralizers { get; init; }
public int HeatShields { get; init; }
public int HeatImmunitySteps { get; init; }
public int Count(ERemedyType remedy)
{
return remedy switch {
ERemedyType.FuelNeutralizer => FuelNeutralizers,
ERemedyType.CoolantNeutralizer => CoolantNeutralizers,
ERemedyType.ElectricityNeutralizer => ElectricityNeutralizers,
ERemedyType.HeatShield => HeatShields,
_ => throw new ArgumentOutOfRangeException(nameof(remedy), remedy, "Unsupported remedy.")
};
}
public RobotState Add(ERemedyType remedy, int amount)
{
return remedy switch {
ERemedyType.FuelNeutralizer => this with { FuelNeutralizers = FuelNeutralizers + amount },
ERemedyType.CoolantNeutralizer => this with { CoolantNeutralizers = CoolantNeutralizers + amount },
ERemedyType.ElectricityNeutralizer => this with { ElectricityNeutralizers = ElectricityNeutralizers + amount },
ERemedyType.HeatShield => this with { HeatShields = HeatShields + amount },
_ => throw new ArgumentOutOfRangeException(nameof(remedy), remedy, "Unsupported remedy.")
};
}
public RobotState Spend(ERemedyType remedy)
{
return Count(remedy) <= 0 ? this : Add(remedy, -1);
}
}
public sealed record RulePredicate
{
public ERulePredicateKind Kind { get; init; }
public GridPosition Position { get; init; } = new(0, 0);
public int Turn { get; init; }
public ELevelState LevelState { get; init; }
public EPropSwitchState PropSwitchState { get; init; }
public EConsumerServiceState ConsumerServiceState { get; init; }
public ECarrierType Carrier { get; init; }
public EBand Band { get; init; }
public bool BoolValue { get; init; }
}
public sealed record RuleEffect
{
public ERuleEffectKind Kind { get; init; }
public GridPosition Position { get; init; } = new(0, 0);
public ECarrierType Carrier { get; init; }
public ERemedyType Remedy { get; init; }
public float Amount { get; init; }
public EPropSwitchState PropSwitchState { get; init; }
public string Message { get; init; } = string.Empty;
}
public sealed record RuleEventState
{
public string Id { get; init; } = string.Empty;
public bool Enabled { get; init; } = true;
public bool Repeat { get; init; }
public bool Triggered { get; init; }
public int Priority { get; init; }
public ERuleEventPhase Phase { get; init; }
public IReadOnlyList<RulePredicate> Predicates { get; init; } = Array.Empty<RulePredicate>();
public IReadOnlyList<RuleEffect> Effects { get; init; } = Array.Empty<RuleEffect>();
public string ForecastText { get; init; } = string.Empty;
}
public sealed record Forecast(EForecastKind Kind, GridPosition? Position, int Turns, string Message);
public sealed record ValidationIssue(string Message, GridPosition? Position = null);
public sealed record ValidationReport
{
public IReadOnlyList<ValidationIssue> Errors { get; init; } = Array.Empty<ValidationIssue>();
public IReadOnlyList<ValidationIssue> Warnings { get; init; } = Array.Empty<ValidationIssue>();
public bool IsValid => Errors.Count == 0;
}
public sealed record GlobalState
{
public int Turn { get; init; }
public int ActionsRemaining { get; init; } = Balancing.Current.ActionsPerTurn;
public ELevelState LevelState { get; init; } = ELevelState.Stable;
public string Status { get; init; } = "STABLE";
public bool AllSeeingEyeUnlocked { get; init; }
public bool TerminalLoss { get; init; }
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}
public sealed record LevelState
{
public static LevelState Create(string name, int width, int height)
{
if (width < Balancing.Current.MinimumLevelSize || height < Balancing.Current.MinimumLevelSize)
throw new ArgumentOutOfRangeException(nameof(width), $"Levels must be at least {Balancing.Current.MinimumLevelSize}x{Balancing.Current.MinimumLevelSize}.");
var terrain = Enumerable.Repeat(ECellTerrain.Floor, width * height).ToArray();
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
if (x == 0 || y == 0 || x == width - 1 || y == height - 1)
terrain[y * width + x] = ECellTerrain.Wall;
}
}
var level = new LevelState {
Name = name,
Width = width,
Height = height,
Terrain = terrain,
Fuel = CreateUnderground(width, height),
Coolant = CreateUnderground(width, height),
Electricity = CreateUnderground(width, height),
Surface = CreateSurface(width, height),
Props = CreateProps(width, height),
Robot = new() { Position = new(1, 1) }
};
return level with { Forecasts = Array.Empty<Forecast>() };
}
public bool InBounds(GridPosition position)
{
return position.X >= 0 && position.Y >= 0 && position.X < Width && position.Y < Height;
}
public int Index(GridPosition position)
{
EnsureInBounds(position);
return position.Y * Width + position.X;
}
public ECellTerrain GetTerrain(GridPosition position)
{
return Terrain[Index(position)];
}
public UndergroundCell GetUnderground(GridPosition position, ECarrierType carrier)
{
return Layer(carrier)[Index(position)];
}
public SurfaceState GetSurface(GridPosition position)
{
return Surface[Index(position)];
}
public PropState GetProp(GridPosition position)
{
return Props[Index(position)];
}
public bool IsFloor(GridPosition position)
{
return InBounds(position) && GetTerrain(position) == ECellTerrain.Floor;
}
public bool IsClosedDoorEdge(GridPosition a, GridPosition b)
{
return Doors.Any(door => door.State == EDoorState.Closed && SameEdge(door.A, door.B, a, b));
}
public LevelState SetTerrain(GridPosition position, ECellTerrain terrain)
{
var next = Terrain.ToArray();
next[Index(position)] = terrain;
var level = this with { Terrain = next };
return terrain == ECellTerrain.Wall ? level.ClearFloorOnlyState(position) : level;
}
public LevelState SetUnderground(GridPosition position, ECarrierType carrier, UndergroundCell cell)
{
var next = Layer(carrier).ToArray();
next[Index(position)] = cell;
return carrier switch {
ECarrierType.Fuel => this with { Fuel = next },
ECarrierType.Coolant => this with { Coolant = next },
ECarrierType.Electricity => this with { Electricity = next },
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
public LevelState SetSurface(GridPosition position, SurfaceState surface)
{
var next = Surface.ToArray();
next[Index(position)] = surface.Clamp();
return this with { Surface = next };
}
public LevelState SetProp(GridPosition position, PropState prop)
{
var next = Props.ToArray();
next[Index(position)] = prop;
return this with { Props = next };
}
public LevelState WithRuntimeArrays(UndergroundCell[] fuel, UndergroundCell[] coolant, UndergroundCell[] electricity, SurfaceState[] surface, PropState[] props)
{
return this with {
Fuel = fuel,
Coolant = coolant,
Electricity = electricity,
Surface = surface,
Props = props
};
}
public IReadOnlyList<UndergroundCell> Layer(ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => Fuel,
ECarrierType.Coolant => Coolant,
ECarrierType.Electricity => Electricity,
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
private LevelState ClearFloorOnlyState(GridPosition position)
{
return SetSurface(position, new())
.SetProp(position, new())
.SetUnderground(position, ECarrierType.Fuel, new())
.SetUnderground(position, ECarrierType.Coolant, new())
.SetUnderground(position, ECarrierType.Electricity, new());
}
private void EnsureInBounds(GridPosition position)
{
if (!InBounds(position))
throw new ArgumentOutOfRangeException(nameof(position), $"Position {position.X},{position.Y} is outside {Width}x{Height}.");
}
private static bool SameEdge(GridPosition edgeA, GridPosition edgeB, GridPosition a, GridPosition b)
{
return edgeA == a && edgeB == b || edgeA == b && edgeB == a;
}
private static UndergroundCell[] CreateUnderground(int width, int height)
{
return Enumerable.Range(0, width * height).Select(_ => new UndergroundCell()).ToArray();
}
private static SurfaceState[] CreateSurface(int width, int height)
{
return Enumerable.Range(0, width * height).Select(_ => new SurfaceState()).ToArray();
}
private static PropState[] CreateProps(int width, int height)
{
return Enumerable.Range(0, width * height).Select(_ => new PropState()).ToArray();
}
public string Name { get; init; } = "New Reactor";
public int Width { get; init; } = Balancing.Current.DefaultLevelWidth;
public int Height { get; init; } = Balancing.Current.DefaultLevelHeight;
public ECellTerrain[] Terrain { get; init; } = Enumerable.Repeat(ECellTerrain.Floor, Balancing.Current.DefaultLevelWidth * Balancing.Current.DefaultLevelHeight).ToArray();
public UndergroundCell[] Fuel { get; init; } = CreateUnderground(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public UndergroundCell[] Coolant { get; init; } = CreateUnderground(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public UndergroundCell[] Electricity { get; init; } = CreateUnderground(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public SurfaceState[] Surface { get; init; } = CreateSurface(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public PropState[] Props { get; init; } = CreateProps(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public IReadOnlyList<DoorState> Doors { get; init; } = Array.Empty<DoorState>();
public IReadOnlyList<LeakState> Leaks { get; init; } = Array.Empty<LeakState>();
public IReadOnlyList<ReactorBinding> Reactors { get; init; } = Array.Empty<ReactorBinding>();
public IReadOnlyList<RuleEventState> RuleEvents { get; init; } = Array.Empty<RuleEventState>();
public RobotState Robot { get; init; } = new();
public GlobalState Global { get; init; } = new();
public IReadOnlyList<Forecast> Forecasts { get; init; } = Array.Empty<Forecast>();
}

View File

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

@@ -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,84 +1,30 @@
namespace ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Simulation;
public sealed class SimulationEngine
{
public LevelState MoveRobot(LevelState level, GridPosition destination)
{
if (!CanSpendAction(level) || !level.IsFloor(destination) || level.Robot.Position.ManhattanDistance(destination) != 1)
return Refuse(level, "MOVE BLOCKED");
return SpendAction(level with {
Robot = level.Robot with {
Position = destination,
HeatImmunitySteps = Math.Max(0, level.Robot.HeatImmunitySteps - 1)
}
});
return PlayerActionSystem.MoveRobot(level, destination, SpendAction);
}
public LevelState InteractProp(LevelState level)
{
if (!CanSpendAction(level))
return Refuse(level, "NO ACTIONS");
var position = level.Robot.Position;
var prop = level.GetProp(position);
if (prop.Type == EPropType.None)
return Refuse(level, "NO PROP");
var next = prop.Type switch {
EPropType.Flow or EPropType.Consumer => ToggleProp(level, position, prop),
EPropType.TJunction => level.SetProp(position, prop with { TJunctionMode = NextTJunctionMode(prop.TJunctionMode) }),
EPropType.CrossJunction => level.SetProp(position, prop with { CrossJunctionMode = NextCrossJunctionMode(prop.CrossJunctionMode) }),
EPropType.Door => ToggleDoor(level, position),
EPropType.AllSeeingEyeTerminal => level with { Global = level.Global with { AllSeeingEyeUnlocked = true, Status = "ALL-SEEING-EYE ONLINE" } },
EPropType.RemedySupply => PickUpRemedy(level, position, prop),
EPropType.ReactorControl => ActivateReactor(level),
_ => level
};
return SpendAction(next);
return PlayerActionSystem.InteractProp(level, SpendAction);
}
public LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy)
{
if (!CanSpendAction(level))
return Refuse(level, "NO ACTIONS");
var leakIndex = level.Leaks.ToList().FindIndex(leak => !leak.Repaired && leak.Carrier == carrier && leak.AccessPosition == level.Robot.Position);
if (leakIndex < 0)
return Refuse(level, "NO REACHABLE LEAK");
var leak = level.Leaks[leakIndex];
var next = useRemedy ? ApplyElementRemedy(level, leak) : RepairLeak(level, leakIndex, leak);
return SpendAction(next);
return PlayerActionSystem.InteractLeak(level, carrier, useRemedy, SpendAction);
}
public LevelState ApplyHeatShield(LevelState level)
{
if (!CanSpendAction(level) || level.Robot.HeatShields <= 0)
return Refuse(level, "NO HEAT SHIELD");
return SpendAction(level with {
Robot = level.Robot.Spend(ERemedyType.HeatShield) with { HeatImmunitySteps = Balancing.Current.HeatShieldSteps }
});
return PlayerActionSystem.ApplyHeatShield(level, SpendAction);
}
public LevelState ActivateReactor(LevelState level)
{
var reactorIndex = level.Reactors.ToList().FindIndex(reactor => reactor.ControlPosition == level.Robot.Position);
if (reactorIndex < 0)
return Refuse(level, "NO REACTOR CONTROL");
var reactor = level.Reactors[reactorIndex];
if (!reactor.Ready)
return Refuse(level, "REACTOR NOT READY");
var reactors = level.Reactors.ToArray();
reactors[reactorIndex] = reactor with { Activated = true };
return level with {
Reactors = reactors,
Global = level.Global with { LevelState = ELevelState.Won, Status = "REACTOR ONLINE" }
};
return ReactorSystem.Activate(level);
}
public LevelState EndTurn(LevelState level)
@@ -93,20 +39,14 @@ public sealed class SimulationEngine
public IReadOnlyList<Forecast> Forecast(LevelState level)
{
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, false);
return ForecastSystem.Forecast(level, simulated => ResolveTurn(simulated, false));
}
return forecasts.DistinctBy(forecast => (forecast.Kind, forecast.Position, forecast.Message)).OrderBy(forecast => forecast.Turns).ThenBy(forecast => forecast.Message).ToArray();
private LevelState 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)
@@ -115,15 +55,15 @@ public sealed class SimulationEngine
if (!report.IsValid)
return level with { Global = level.Global with { LevelState = ELevelState.Lost, Status = report.Errors[0].Message } };
var next = ApplyRuleEvents(level, ERuleEventPhase.StartOfSimulation);
next = PropagateNetworks(next);
next = ResolveConsumers(next);
next = InjectLeaks(next);
next = ResolveSurfaceInteractions(next);
next = ResolveRobotSafety(next);
next = DeriveReactorAndLevelState(next);
next = ApplyRuleEvents(next, ERuleEventPhase.EndOfTurn);
next = AdvanceDurations(next);
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,
@@ -134,604 +74,5 @@ public sealed class SimulationEngine
return refreshForecasts ? next with { Forecasts = Forecast(next) } : next;
}
private LevelState PropagateNetworks(LevelState level)
{
var fuel = ClearTransient(level.Fuel);
var coolant = ClearTransient(level.Coolant);
var electricity = ClearTransient(level.Electricity);
var next = level.WithRuntimeArrays(fuel, coolant, electricity, level.Surface.ToArray(), level.Props.ToArray());
foreach (var carrier in Enum.GetValues<ECarrierType>())
next = PropagateCarrier(next, carrier);
return next;
}
private static UndergroundCell[] ClearTransient(IReadOnlyList<UndergroundCell> layer)
{
return layer.Select(cell => cell with { Amount = 0, Intensity = 0 }).ToArray();
}
private LevelState PropagateCarrier(LevelState level, ECarrierType carrier)
{
var layer = level.Layer(carrier).ToArray();
var sources = AllPositions(level).Where(position => level.GetProp(position) is { Type: EPropType.Flow, SwitchState: EPropSwitchState.Enabled, Carrier: var sourceCarrier } && sourceCarrier == carrier && level.GetUnderground(position, carrier).CarriesFlow).ToArray();
foreach (var source in sources)
ApplySourceFlow(level, layer, source, carrier);
return carrier switch {
ECarrierType.Fuel => level with { Fuel = layer },
ECarrierType.Coolant => level with { Coolant = layer },
ECarrierType.Electricity => level with { Electricity = layer },
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
private static void ApplySourceFlow(LevelState level, UndergroundCell[] layer, GridPosition source, ECarrierType carrier)
{
var open = new Queue<(GridPosition Position, int Distance, float AmountFactor, float IntensityFactor)>();
var best = new Dictionary<GridPosition, float>();
open.Enqueue((source, 0, 1, 1));
best[source] = 1;
while (open.Count > 0)
{
var current = open.Dequeue();
var amount = Balancing.Current.ClampValue((Balancing.Current.SourceAmount * current.AmountFactor) - (current.Distance * Balancing.Current.DistanceAmountFalloff));
var intensity = Balancing.Current.ClampValue((Balancing.Current.SourceIntensity * current.IntensityFactor) - (current.Distance * Balancing.Current.DistanceIntensityFalloff));
var index = level.Index(current.Position);
layer[index] = layer[index] with {
Amount = Math.Max(layer[index].Amount, amount),
Intensity = Math.Max(layer[index].Intensity, intensity)
};
foreach (var next in current.Position.Neighbors().Where(level.InBounds))
{
if (!level.GetUnderground(next, carrier).CarriesFlow)
continue;
var weights = BranchWeights(level, current.Position, next);
var amountFactor = current.AmountFactor * weights.Amount;
var intensityFactor = current.IntensityFactor * weights.Intensity;
if (amountFactor <= 0 || intensityFactor <= 0)
continue;
if (best.TryGetValue(next, out var oldBest) && oldBest >= amountFactor)
continue;
best[next] = amountFactor;
open.Enqueue((next, current.Distance + 1, amountFactor, intensityFactor));
}
}
}
private static (float Amount, float Intensity) BranchWeights(LevelState level, GridPosition from, GridPosition to)
{
var prop = level.GetProp(from);
return prop.Type switch {
EPropType.TJunction => TJunctionWeights(prop.TJunctionMode),
EPropType.CrossJunction => CrossJunctionWeights(prop.CrossJunctionMode),
_ => (1, 1)
};
}
private static (float Amount, float Intensity) TJunctionWeights(ETJunctionMode mode)
{
var weight = mode switch {
ETJunctionMode.ZeroFour => 0,
ETJunctionMode.OneThree => 0.25f,
ETJunctionMode.TwoTwo => 0.5f,
ETJunctionMode.ThreeOne => 0.75f,
ETJunctionMode.FourZero => 1,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported T-junction mode.")
};
return (weight, weight);
}
private static (float Amount, float Intensity) CrossJunctionWeights(ECrossJunctionMode mode)
{
var weight = mode switch {
ECrossJunctionMode.ZeroThreeThree => 0,
ECrossJunctionMode.ThreeZeroThree => 0.5f,
ECrossJunctionMode.ThreeThreeZero => 0.5f,
ECrossJunctionMode.TwoTwoTwo => 1f / 3f,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported cross-junction mode.")
};
return (weight, weight);
}
private LevelState ResolveConsumers(LevelState level)
{
var props = level.Props.ToArray();
foreach (var position in AllPositions(level))
{
var index = level.Index(position);
var prop = props[index];
if (prop.Type != EPropType.Consumer)
continue;
if (prop.SwitchState == EPropSwitchState.Disabled)
{
props[index] = prop with { ServiceState = EConsumerServiceState.Disabled };
continue;
}
var underground = level.GetUnderground(position, prop.Carrier);
var supplied = underground.Amount >= Balancing.Current.ConsumerRequiredAmount && underground.Intensity >= Balancing.Current.ConsumerRequiredIntensity;
props[index] = prop with { ServiceState = supplied ? EConsumerServiceState.Producing : EConsumerServiceState.Starved };
}
return level with { Props = props };
}
private LevelState InjectLeaks(LevelState level)
{
var surface = level.Surface.ToArray();
foreach (var leak in level.Leaks.Where(leak => !leak.Repaired))
{
var underground = level.GetUnderground(leak.UndergroundPosition, leak.Carrier);
if (underground.State != EUndergroundState.Leaking)
continue;
var accessIndex = level.Index(leak.AccessPosition);
if (surface[accessIndex].Blocks(leak.Carrier))
continue;
var amount = Balancing.Current.LeakBaseAmount + underground.Amount * Balancing.Current.LeakAmountScale + underground.Intensity * Balancing.Current.LeakIntensityScale;
surface[accessIndex] = AddSurfaceCarrier(surface[accessIndex], leak.Carrier, amount);
}
return level with { Surface = surface.Select(cell => cell.Clamp()).ToArray() };
}
private LevelState ResolveSurfaceInteractions(LevelState level)
{
var deltas = Enumerable.Range(0, level.Width * level.Height).Select(_ => new SurfaceDelta()).ToArray();
foreach (var position in AllPositions(level).Where(level.IsFloor))
ApplySameCellInteractions(level, position, deltas);
foreach (var position in AllPositions(level).Where(level.IsFloor))
{
foreach (var neighbor in position.Neighbors().Where(level.IsFloor))
{
if (level.Index(position) >= level.Index(neighbor) || level.IsClosedDoorEdge(position, neighbor))
continue;
ApplyAdjacentInteractions(level, position, neighbor, deltas);
}
}
var surface = level.Surface.ToArray();
for (var i = 0; i < surface.Length; i++)
surface[i] = deltas[i].Apply(surface[i]).Clamp();
return level with { Surface = surface };
}
private static void ApplySameCellInteractions(LevelState level, GridPosition position, SurfaceDelta[] deltas)
{
var surface = level.GetSurface(position);
ApplyPair(level, position, position, ECarrierType.Fuel, BandFuel(surface.Fuel), ECarrierType.Electricity, BandElectricity(surface.Electricity), deltas);
ApplyPair(level, position, position, ECarrierType.Fuel, BandFuel(surface.Fuel), null, BandHeat(surface.Heat), deltas);
ApplyPair(level, position, position, ECarrierType.Coolant, BandCoolant(surface.Coolant), ECarrierType.Electricity, BandElectricity(surface.Electricity), deltas);
ApplyPair(level, position, position, ECarrierType.Coolant, BandCoolant(surface.Coolant), null, BandHeat(surface.Heat), deltas);
}
private static void ApplyAdjacentInteractions(LevelState level, GridPosition a, GridPosition b, SurfaceDelta[] deltas)
{
var surfaceA = level.GetSurface(a);
var surfaceB = level.GetSurface(b);
FlowBetween(level, a, b, surfaceA.Fuel, surfaceB.Fuel, EPairEffect.FuelFlow, deltas);
FlowBetween(level, a, b, surfaceA.Coolant, surfaceB.Coolant, EPairEffect.CoolFlow, deltas);
FlowBetween(level, a, b, surfaceA.Electricity, surfaceB.Electricity, EPairEffect.ChargeFlow, deltas);
FlowBetween(level, a, b, surfaceA.Heat, surfaceB.Heat, EPairEffect.HeatFlow, deltas);
}
private static void ApplyPair(LevelState level, GridPosition a, GridPosition b, ECarrierType? rowCarrier, EBand rowBand, ECarrierType? colCarrier, EBand colBand, SurfaceDelta[] deltas)
{
ApplyEffect(level, a, b, PairEffect(rowCarrier, rowBand, colCarrier, colBand), deltas);
}
private static EPairEffect PairEffect(ECarrierType? rowCarrier, EBand rowBand, ECarrierType? colCarrier, EBand colBand)
{
if (rowBand == EBand.Safe && colBand == EBand.Safe)
return EPairEffect.Hold;
if (rowCarrier == colCarrier)
return rowCarrier switch {
ECarrierType.Fuel => EPairEffect.FuelFlow,
ECarrierType.Coolant => EPairEffect.CoolFlow,
ECarrierType.Electricity => EPairEffect.ChargeFlow,
_ => EPairEffect.HeatFlow
};
if (rowCarrier == ECarrierType.Fuel && colCarrier == ECarrierType.Electricity)
return rowBand == EBand.Critical || colBand == EBand.Critical ? EPairEffect.Ignite2 : EPairEffect.Ignite1;
if (rowCarrier == ECarrierType.Fuel && colCarrier is null)
return rowBand == EBand.Critical || colBand == EBand.Critical ? EPairEffect.Ignite2 : EPairEffect.Warm1;
if (rowCarrier == ECarrierType.Coolant && colCarrier == ECarrierType.Electricity)
return rowBand == EBand.Critical || colBand == EBand.Critical ? EPairEffect.Short2 : EPairEffect.Short1;
if (rowCarrier == ECarrierType.Coolant && colCarrier is null)
return rowBand == EBand.Critical || colBand == EBand.Critical ? EPairEffect.Quench2 : EPairEffect.Quench1;
return EPairEffect.Hold;
}
private static void ApplyEffect(LevelState level, GridPosition a, GridPosition b, EPairEffect effect, SurfaceDelta[] deltas)
{
var index = level.Index(a);
switch (effect)
{
case EPairEffect.Warm1:
deltas[index].Heat += Balancing.Current.Warm1Amount;
break;
case EPairEffect.Warm2:
deltas[index].Heat += Balancing.Current.Warm2Amount;
break;
case EPairEffect.Quench1:
deltas[index].Heat -= Balancing.Current.Quench1Amount;
break;
case EPairEffect.Quench2:
deltas[index].Heat -= Balancing.Current.Quench2Amount;
break;
case EPairEffect.Short1:
deltas[index].Heat += Balancing.Current.Short1Heat;
deltas[index].Electricity -= Balancing.Current.Short1Discharge;
break;
case EPairEffect.Short2:
deltas[index].Heat += Balancing.Current.Short2Heat;
deltas[index].Electricity -= Balancing.Current.Short2Discharge;
break;
case EPairEffect.Ignite1:
deltas[index].Heat += Balancing.Current.Ignite1Heat;
deltas[index].Fuel -= Balancing.Current.Ignite1FuelConsumption;
break;
case EPairEffect.Ignite2:
deltas[index].Heat += Balancing.Current.Ignite2Heat;
deltas[index].Fuel -= Balancing.Current.Ignite2FuelConsumption;
break;
}
}
private static void FlowBetween(LevelState level, GridPosition a, GridPosition b, float valueA, float valueB, EPairEffect effect, SurfaceDelta[] deltas)
{
var difference = valueA - valueB;
if (Math.Abs(difference) < 0.01f)
return;
var amount = difference * (effect == EPairEffect.HeatFlow2 ? Balancing.Current.StrongFlowTransferRatio : Balancing.Current.FlowTransferRatio);
var indexA = level.Index(a);
var indexB = level.Index(b);
switch (effect)
{
case EPairEffect.FuelFlow:
deltas[indexA].Fuel -= amount;
deltas[indexB].Fuel += amount;
break;
case EPairEffect.CoolFlow:
deltas[indexA].Coolant -= amount;
deltas[indexB].Coolant += amount;
break;
case EPairEffect.ChargeFlow:
deltas[indexA].Electricity -= amount;
deltas[indexB].Electricity += amount;
break;
case EPairEffect.HeatFlow:
case EPairEffect.HeatFlow2:
deltas[indexA].Heat -= amount;
deltas[indexB].Heat += amount;
break;
}
}
private LevelState ResolveRobotSafety(LevelState level)
{
var surface = level.GetSurface(level.Robot.Position);
var unsafeElement = surface.Fuel >= Balancing.Current.RobotFuelSafetyThreshold || surface.Coolant >= Balancing.Current.RobotCoolantSafetyThreshold || surface.Electricity >= Balancing.Current.RobotElectricitySafetyThreshold;
var unsafeHeat = surface.Heat >= Balancing.Current.RobotHeatSafetyThreshold && level.Robot.HeatImmunitySteps <= 0;
return unsafeElement || unsafeHeat
? level with { Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = "ROBOT LOST" } }
: level;
}
private LevelState DeriveReactorAndLevelState(LevelState level)
{
if (level.Global.LevelState is ELevelState.Lost or ELevelState.Won)
return level;
var reactors = level.Reactors.Select(reactor => reactor with { Ready = IsReactorReady(level, reactor) }).ToArray();
if (reactors.Any(reactor => reactor.Ready))
return level with { Reactors = reactors, Global = level.Global with { LevelState = ELevelState.Ready, Status = "REACTOR READY" } };
var maxHeat = level.Surface.Where((_, index) => level.Terrain[index] == ECellTerrain.Floor).Select(surface => surface.Heat).DefaultIfEmpty(0).Max();
if (maxHeat >= Balancing.Current.TerminalHeat)
return level with { Reactors = reactors, Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = "REACTOR HEAT TERMINAL" } };
var hasCritical = level.Surface.Any(surface => BandFuel(surface.Fuel) == EBand.Critical || BandCoolant(surface.Coolant) == EBand.Critical || BandElectricity(surface.Electricity) == EBand.Critical || BandHeat(surface.Heat) == EBand.Critical);
var hasCaution = hasCritical || level.Props.Any(prop => prop.ServiceState is EConsumerServiceState.Starved or EConsumerServiceState.Disabled) || level.Leaks.Any(leak => !leak.Repaired);
var state = hasCritical ? ELevelState.Critical :
hasCaution ? ELevelState.Caution : ELevelState.Stable;
return level with { Reactors = reactors, Global = level.Global with { LevelState = state, Status = state.ToString().ToUpperInvariant() } };
}
private static bool IsReactorReady(LevelState level, ReactorBinding reactor)
{
return HasProducingConsumer(level, reactor.FuelConsumerPosition, ECarrierType.Fuel)
&& HasProducingConsumer(level, reactor.CoolantConsumerPosition, ECarrierType.Coolant)
&& HasProducingConsumer(level, reactor.ElectricityConsumerPosition, ECarrierType.Electricity)
&& level.GetSurface(reactor.ControlPosition).Heat < Balancing.Current.TerminalHeat;
}
private static bool HasProducingConsumer(LevelState level, GridPosition position, ECarrierType carrier)
{
return level.InBounds(position) && level.GetProp(position) is { Type: EPropType.Consumer, Carrier: var consumerCarrier, ServiceState: EConsumerServiceState.Producing } && consumerCarrier == carrier;
}
private LevelState ApplyRuleEvents(LevelState level, ERuleEventPhase phase)
{
var next = level;
var events = level.RuleEvents.Select((ruleEvent, index) => (Event: ruleEvent, Index: index)).Where(item => item.Event.Enabled && item.Event.Phase == phase && (item.Event.Repeat || !item.Event.Triggered)).OrderBy(item => item.Event.Priority).ToArray();
var ruleEvents = next.RuleEvents.ToArray();
foreach (var item in events)
{
if (!item.Event.Predicates.All(predicate => PredicateMatches(next, predicate)))
continue;
foreach (var effect in item.Event.Effects)
next = ApplyRuleEffect(next, effect);
ruleEvents[item.Index] = item.Event with { Triggered = true };
}
return next with { RuleEvents = ruleEvents };
}
private static bool PredicateMatches(LevelState level, RulePredicate predicate)
{
return predicate.Kind switch {
ERulePredicateKind.TurnAtLeast => level.Global.Turn >= predicate.Turn,
ERulePredicateKind.LevelStateIs => level.Global.LevelState == predicate.LevelState,
ERulePredicateKind.PropStateAt => level.InBounds(predicate.Position) && level.GetProp(predicate.Position).SwitchState == predicate.PropSwitchState,
ERulePredicateKind.ConsumerStateAt => level.InBounds(predicate.Position) && level.GetProp(predicate.Position).ServiceState == predicate.ConsumerServiceState,
ERulePredicateKind.SurfaceBandAt => level.InBounds(predicate.Position) && SurfaceBand(level.GetSurface(predicate.Position), predicate.Carrier) >= predicate.Band,
ERulePredicateKind.RobotAt => level.Robot.Position == predicate.Position,
ERulePredicateKind.AllSeeingEyeUnlocked => level.Global.AllSeeingEyeUnlocked == predicate.BoolValue,
_ => false
};
}
private static LevelState ApplyRuleEffect(LevelState level, RuleEffect effect)
{
return effect.Kind switch {
ERuleEffectKind.StartLeak => StartLeak(level, effect),
ERuleEffectKind.WorsenLeak => level.SetUnderground(effect.Position, effect.Carrier, level.GetUnderground(effect.Position, effect.Carrier) with { State = EUndergroundState.Leaking }),
ERuleEffectKind.RepairNetworkCell => level.SetUnderground(effect.Position, effect.Carrier, level.GetUnderground(effect.Position, effect.Carrier) with { State = EUndergroundState.Intact }),
ERuleEffectKind.DisableNetworkCell => level.SetUnderground(effect.Position, effect.Carrier, new()),
ERuleEffectKind.SetPropEnabled => level.SetProp(effect.Position, level.GetProp(effect.Position) with { SwitchState = effect.PropSwitchState }),
ERuleEffectKind.AddSurfaceHazard => level.SetSurface(effect.Position, AddSurfaceCarrier(level.GetSurface(effect.Position), effect.Carrier, effect.Amount)),
ERuleEffectKind.AddHeat => level.SetSurface(effect.Position, level.GetSurface(effect.Position) with { Heat = level.GetSurface(effect.Position).Heat + effect.Amount }),
ERuleEffectKind.AddInventory => level with { Robot = level.Robot.Add(effect.Remedy, (int)effect.Amount) },
ERuleEffectKind.MarkTerminalLoss => level with { Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = string.IsNullOrWhiteSpace(effect.Message) ? "TERMINAL FAILURE" : effect.Message } },
ERuleEffectKind.EmitWarning => level with { Global = level.Global with { Warnings = [.. level.Global.Warnings, effect.Message] } },
_ => level
};
}
private static LevelState StartLeak(LevelState level, RuleEffect effect)
{
var leak = new LeakState {
Carrier = effect.Carrier,
UndergroundPosition = effect.Position,
AccessPosition = effect.Position
};
return level.SetUnderground(effect.Position, effect.Carrier, level.GetUnderground(effect.Position, effect.Carrier) with { State = EUndergroundState.Leaking }) with { Leaks = [.. level.Leaks, leak] };
}
private LevelState AdvanceDurations(LevelState level)
{
var surface = level.Surface.Select(cell => cell with {
FuelBlockTurns = Math.Max(0, cell.FuelBlockTurns - 1),
CoolantBlockTurns = Math.Max(0, cell.CoolantBlockTurns - 1),
ElectricityBlockTurns = Math.Max(0, cell.ElectricityBlockTurns - 1)
})
.ToArray();
return level with { Surface = surface };
}
private static LevelState ToggleProp(LevelState level, GridPosition position, PropState prop)
{
var switchState = prop.SwitchState == EPropSwitchState.Enabled ? EPropSwitchState.Disabled : EPropSwitchState.Enabled;
return level.SetProp(position, prop with { SwitchState = switchState });
}
private static LevelState ToggleDoor(LevelState level, GridPosition position)
{
var doors = level.Doors.ToArray();
var index = Array.FindIndex(doors, door => door.A == position || door.B == position);
if (index < 0)
return level;
doors[index] = doors[index] with { State = doors[index].State == EDoorState.Open ? EDoorState.Closed : EDoorState.Open };
return level with { Doors = doors };
}
private static LevelState PickUpRemedy(LevelState level, GridPosition position, PropState prop)
{
if (prop.Depleted || level.Robot.Count(prop.RemedyType) >= Balancing.Current.InventoryCapacityPerRemedy)
return level;
return level.SetProp(position, prop with { Depleted = true }) with { Robot = level.Robot.Add(prop.RemedyType, 1) };
}
private static LevelState RepairLeak(LevelState level, int leakIndex, LeakState leak)
{
var leaks = level.Leaks.ToArray();
leaks[leakIndex] = leak with { Repaired = true };
return level.SetUnderground(leak.UndergroundPosition, leak.Carrier, level.GetUnderground(leak.UndergroundPosition, leak.Carrier) with { State = EUndergroundState.Intact }) with { Leaks = leaks };
}
private static LevelState ApplyElementRemedy(LevelState level, LeakState leak)
{
var remedy = leak.Carrier switch {
ECarrierType.Fuel => ERemedyType.FuelNeutralizer,
ECarrierType.Coolant => ERemedyType.CoolantNeutralizer,
ECarrierType.Electricity => ERemedyType.ElectricityNeutralizer,
_ => throw new ArgumentOutOfRangeException(nameof(leak), leak.Carrier, "Unsupported leak carrier.")
};
if (level.Robot.Count(remedy) <= 0)
return Refuse(level, "NO REMEDY");
var surface = RemoveSurfaceCarrier(level.GetSurface(leak.AccessPosition), leak.Carrier);
return level.SetSurface(leak.AccessPosition, surface) with { Robot = level.Robot.Spend(remedy) };
}
private static SurfaceState AddSurfaceCarrier(SurfaceState surface, ECarrierType carrier, float amount)
{
return carrier switch {
ECarrierType.Fuel => surface with { Fuel = surface.Fuel + amount },
ECarrierType.Coolant => surface with { Coolant = surface.Coolant + amount },
ECarrierType.Electricity => surface with { Electricity = surface.Electricity + amount },
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
private static SurfaceState RemoveSurfaceCarrier(SurfaceState surface, ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => surface with { Fuel = 0, FuelBlockTurns = Balancing.Current.RemedyBlockTurns },
ECarrierType.Coolant => surface with { Coolant = 0, CoolantBlockTurns = Balancing.Current.RemedyBlockTurns },
ECarrierType.Electricity => surface with { Electricity = 0, ElectricityBlockTurns = Balancing.Current.RemedyBlockTurns },
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
private LevelState SpendAction(LevelState level)
{
var actions = Math.Max(0, level.Global.ActionsRemaining - 1);
var next = level with { Global = level.Global with { ActionsRemaining = actions } };
return actions == 0 ? ResolveTurn(next) : next;
}
private static bool CanSpendAction(LevelState level)
{
return level.Global.LevelState is not (ELevelState.Lost or ELevelState.Won) && level.Global.ActionsRemaining > 0;
}
private static LevelState Refuse(LevelState level, string message)
{
return level with { Global = level.Global with { Status = message } };
}
private static ETJunctionMode NextTJunctionMode(ETJunctionMode mode)
{
return mode == ETJunctionMode.FourZero ? ETJunctionMode.ZeroFour : mode + 1;
}
private static ECrossJunctionMode NextCrossJunctionMode(ECrossJunctionMode mode)
{
return mode == ECrossJunctionMode.TwoTwoTwo ? ECrossJunctionMode.ZeroThreeThree : mode + 1;
}
private static EBand SurfaceBand(SurfaceState surface, ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => BandFuel(surface.Fuel),
ECarrierType.Coolant => BandCoolant(surface.Coolant),
ECarrierType.Electricity => BandElectricity(surface.Electricity),
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
private static EBand BandFuel(float value)
{
return Balancing.Current.Band(value, Balancing.Current.FuelCaution, Balancing.Current.FuelCritical);
}
private static EBand BandCoolant(float value)
{
return Balancing.Current.Band(value, Balancing.Current.CoolantCaution, Balancing.Current.CoolantCritical);
}
private static EBand BandElectricity(float value)
{
return Balancing.Current.Band(value, Balancing.Current.ElectricityCaution, Balancing.Current.ElectricityCritical);
}
private static EBand BandHeat(float value)
{
return Balancing.Current.Band(value, Balancing.Current.HeatCaution, Balancing.Current.HeatCritical);
}
private static LevelState CopyForForecast(LevelState level)
{
return level with {
Terrain = level.Terrain.ToArray(),
Fuel = level.Fuel.ToArray(),
Coolant = level.Coolant.ToArray(),
Electricity = level.Electricity.ToArray(),
Surface = level.Surface.ToArray(),
Props = level.Props.ToArray(),
Forecasts = Array.Empty<Forecast>()
};
}
private static void AddForecasts(List<Forecast> forecasts, LevelState level, int turn)
{
if (level.Global.LevelState == ELevelState.Lost)
forecasts.Add(new(EForecastKind.TerminalLoss, level.Robot.Position, turn, level.Global.Status));
if (level.Global.LevelState == ELevelState.Ready)
forecasts.Add(new(EForecastKind.ReactorReady, null, turn, "REACTOR READY"));
foreach (var position in AllPositions(level))
{
var prop = level.GetProp(position);
if (prop.Type == EPropType.Consumer && prop.ServiceState == EConsumerServiceState.Starved)
forecasts.Add(new(EForecastKind.ConsumerStarved, position, turn, $"{prop.Carrier} consumer starved"));
var surface = level.GetSurface(position);
if (BandFuel(surface.Fuel) == EBand.Critical || BandCoolant(surface.Coolant) == EBand.Critical || BandElectricity(surface.Electricity) == EBand.Critical || BandHeat(surface.Heat) == EBand.Critical)
forecasts.Add(new(EForecastKind.HazardGrowth, position, turn, "Critical hazard"));
}
foreach (var ruleEvent in level.RuleEvents.Where(ruleEvent => ruleEvent.Enabled && !string.IsNullOrWhiteSpace(ruleEvent.ForecastText) && ruleEvent.Predicates.All(predicate => PredicateMatches(level, predicate))))
forecasts.Add(new(EForecastKind.RuleEvent, null, turn, ruleEvent.ForecastText));
}
private static IEnumerable<GridPosition> AllPositions(LevelState level)
{
for (var y = 0; y < level.Height; y++)
{
for (var x = 0; x < level.Width; x++)
yield return new(x, y);
}
}
private sealed class SurfaceDelta
{
public SurfaceState Apply(SurfaceState surface)
{
return surface with {
Fuel = surface.Fuel + Fuel,
Coolant = surface.Coolant + Coolant,
Electricity = surface.Electricity + Electricity,
Heat = surface.Heat + Heat
};
}
public float Fuel { get; set; }
public float Coolant { get; set; }
public float Electricity { get; set; }
public float Heat { get; set; }
}
private readonly LevelValidator m_Validator = new();
}

View File

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

View File

@@ -42,7 +42,8 @@
<ToggleButton IsChecked="{Binding IsSelected, Mode=TwoWay}"
Checked="ToolToggle_Checked" ToolTipService.ToolTip="{Binding Label}"
Width="112" MinHeight="46" Padding="6" Margin="0,0,8,8">
<TextBlock Text="{Binding Label}" TextWrapping="WrapWholeWords" TextAlignment="Center"
<TextBlock Text="{Binding Label}" TextWrapping="WrapWholeWords"
TextAlignment="Center"
FontSize="12" />
</ToggleButton>
</DataTemplate>
@@ -50,7 +51,8 @@
</ItemsControl>
<TextBlock Text="Left click selects or paints. Right click clears surface prop and hazards."
Foreground="#9EA7AE" TextWrapping="Wrap" />
<TextBlock Text="Door chooses the first adjacent floor edge. Reactor controls auto-bind to the first available consumers."
<TextBlock
Text="Door and wall electricity leaks use two clicks: choose the source cell, then the adjacent floor face."
Foreground="#9EA7AE"
TextWrapping="Wrap" />
</StackPanel>
@@ -94,6 +96,37 @@
<TextBlock Text="Selected Cell" FontSize="16" FontWeight="SemiBold" Foreground="#F4F1E8" />
<TextBlock x:Name="CellText" Foreground="#CCD4DA" TextWrapping="Wrap" />
<TextBlock Text="Editor Workflow" FontSize="16" FontWeight="SemiBold" Foreground="#F4F1E8" />
<TextBlock x:Name="WorkflowText" Foreground="#CCD4DA" TextWrapping="Wrap" />
<TextBlock Text="Reactor Binding" FontSize="16" FontWeight="SemiBold" Foreground="#F4F1E8" />
<TextBlock x:Name="ReactorBindingText" Foreground="#CCD4DA" TextWrapping="Wrap" />
<Grid ColumnSpacing="8" RowSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Content="Fuel" Click="BindFuel_Click" HorizontalAlignment="Stretch" />
<Button Grid.Column="1" Content="Coolant" Click="BindCoolant_Click"
HorizontalAlignment="Stretch" />
<Button Grid.Column="2" Content="Electric" Click="BindElectricity_Click"
HorizontalAlignment="Stretch" />
</Grid>
<TextBlock Text="Rule Events" FontSize="16" FontWeight="SemiBold" Foreground="#F4F1E8" />
<TextBlock x:Name="RuleEventText" Foreground="#CCD4DA" TextWrapping="Wrap" />
<Grid ColumnSpacing="8" RowSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Content="Warn Next Turn" Click="AddWarningRule_Click" HorizontalAlignment="Stretch" />
<Button Grid.Column="1" Content="Leak Next Turn" Click="AddLeakRule_Click"
HorizontalAlignment="Stretch" />
</Grid>
<Button Content="Remove Last Rule" Click="RemoveLastRule_Click" HorizontalAlignment="Stretch" />
<TextBlock Text="Forecasts" FontSize="16" FontWeight="SemiBold" Foreground="#F4F1E8" />
<ItemsControl x:Name="ForecastList">
<ItemsControl.ItemTemplate>

View File

@@ -1,9 +1,9 @@
using Microsoft.Graphics.Canvas;
using Microsoft.Graphics.Canvas;
using Microsoft.Graphics.Canvas.Text;
using Microsoft.Graphics.Canvas.UI;
using Microsoft.Graphics.Canvas.UI.Xaml;
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using ReactorMaintenance.Simulation;
using System.Globalization;
@@ -22,15 +22,20 @@ public sealed partial class MainWindow
{
public Rect CellRect(GridPosition position)
{
return new(OriginX + position.X * CellSize, OriginY + position.Y * CellSize, CellSize, CellSize);
return new(OriginX + (position.X * CellSize), OriginY + (position.Y * CellSize), CellSize, CellSize);
}
public Rect DualTileRect(int x, int y)
{
return new(OriginX + ((x - 0.5) * CellSize), OriginY + ((y - 0.5) * CellSize), CellSize, CellSize);
}
}
private sealed record ForecastViewModel(string Message);
private sealed class EditorToolViewModel(EEditorTool tool, string label)
private sealed class EditorToolViewModel(EditorToolCommand command, string label)
{
public EEditorTool Tool { get; } = tool;
public EditorToolCommand Command { get; } = command;
public string Label { get; } = label;
public bool IsSelected { get; set; }
}
@@ -40,7 +45,7 @@ public sealed partial class MainWindow
InitializeComponent();
m_Level = BuildStarterLevel();
m_EditorTools = Enum.GetValues<EEditorTool>().Select(tool => new EditorToolViewModel(tool, ToolLabel(tool)) { IsSelected = tool == m_SelectedTool }).ToArray();
m_EditorTools = BuildEditorTools();
ToolPicker.ItemsSource = m_EditorTools;
RefreshInspector();
}
@@ -69,9 +74,12 @@ public sealed partial class MainWindow
if ((sender as FrameworkElement)?.DataContext is not EditorToolViewModel tool)
return;
m_SelectedTool = tool.Tool;
m_SelectedTool = tool.Command;
ClearPendingEditorOperation();
foreach (var editorTool in m_EditorTools)
editorTool.IsSelected = editorTool == tool;
RefreshInspector();
}
private void New_Click(object sender, RoutedEventArgs e)
@@ -79,6 +87,7 @@ public sealed partial class MainWindow
m_Level = BuildStarterLevel();
m_CurrentFile = null;
m_SelectedCell = null;
ClearPendingEditorOperation();
RefreshInspector();
LevelCanvas.Invalidate();
}
@@ -100,6 +109,8 @@ public sealed partial class MainWindow
m_Level = loaded with { Forecasts = m_Simulation.Forecast(loaded) };
m_CurrentFile = file;
m_SelectedCell = null;
m_SelectedReactorId = m_Level.Reactors.FirstOrDefault()?.ReactorId;
ClearPendingEditorOperation();
RefreshInspector();
LevelCanvas.Invalidate();
}
@@ -202,7 +213,7 @@ public sealed partial class MainWindow
var totalDeltaX = point.Position.X - m_LeftPointerDownPoint.X;
var totalDeltaY = point.Position.Y - m_LeftPointerDownPoint.Y;
if (Math.Sqrt(totalDeltaX * totalDeltaX + totalDeltaY * totalDeltaY) > c_ClickPixelThreshold)
if (Math.Sqrt((totalDeltaX * totalDeltaX) + (totalDeltaY * totalDeltaY)) > c_ClickPixelThreshold)
m_DragExceededClickThreshold = true;
m_PanX += deltaX;
@@ -247,11 +258,13 @@ public sealed partial class MainWindow
return;
m_SelectedCell = position;
if (m_SelectedTool != EEditorTool.Cursor)
if (m_SelectedTool.Tool != EEditorTool.Cursor)
{
m_Level = LevelEditor.Apply(m_Level, position, m_SelectedTool);
m_Level = AutoBindReactors(m_Level);
m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) };
ApplySelectedTool(position);
}
else
{
SelectReactorFromCell(position);
}
RefreshInspector();
@@ -264,6 +277,7 @@ public sealed partial class MainWindow
return;
m_SelectedCell = position;
ClearPendingEditorOperation();
m_Level = m_Level.SetProp(position, new()).SetSurface(position, new()) with {
Leaks = m_Level.Leaks.Where(leak => leak.AccessPosition != position && leak.UndergroundPosition != position).ToArray(),
Doors = m_Level.Doors.Where(door => door.A != position && door.B != position).ToArray(),
@@ -292,12 +306,82 @@ public sealed partial class MainWindow
private void DrawTerrain(CanvasDrawingSession drawing, CanvasLayout layout)
{
foreach (var position in AllPositions())
for (var y = 0; y <= m_Level.Height; y++)
{
var rect = layout.CellRect(position);
var color = m_Level.GetTerrain(position) == ECellTerrain.Wall ? ColorHelper.FromArgb(255, 41, 47, 52) : ColorHelper.FromArgb(255, 32, 38, 42);
for (var x = 0; x <= m_Level.Width; x++)
DrawDualTerrainTile(drawing, layout.DualTileRect(x, y), GetDualTileMask(x, y));
}
}
private void DrawDualTerrainTile(CanvasDrawingSession drawing, Rect rect, int floorMask)
{
if (m_TerrainTilemap is null)
{
DrawFallbackTerrainTile(drawing, rect, floorMask);
return;
}
var wallMask = c_AllCorners ^ floorMask;
drawing.DrawImage(m_TerrainTilemap, rect, TilemapSourceRect(wallMask), 1.0f, CanvasImageInterpolation.HighQualityCubic);
}
private static void DrawFallbackTerrainTile(CanvasDrawingSession drawing, Rect rect, int floorMask)
{
var color = floorMask == c_AllCorners ? ColorHelper.FromArgb(255, 32, 38, 42) : ColorHelper.FromArgb(255, 41, 47, 52);
drawing.FillRectangle(rect, color);
}
private static Rect TilemapSourceRect(int wallMask)
{
var tilePosition = wallMask switch {
c_BottomLeftCorner => new(0, 0),
c_TopRightCorner | c_BottomRightCorner => new(1, 0),
c_TopLeftCorner | c_BottomLeftCorner | c_BottomRightCorner => new(2, 0),
c_BottomLeftCorner | c_BottomRightCorner => new(3, 0),
c_TopLeftCorner | c_BottomRightCorner => new(0, 1),
c_BottomLeftCorner | c_TopRightCorner | c_BottomRightCorner => new(1, 1),
c_AllCorners => new(2, 1),
c_TopLeftCorner | c_BottomLeftCorner | c_TopRightCorner => new(3, 1),
c_TopRightCorner => new(0, 2),
c_TopLeftCorner | c_TopRightCorner => new(1, 2),
c_TopLeftCorner | c_TopRightCorner | c_BottomRightCorner => new(2, 2),
c_BottomLeftCorner | c_TopLeftCorner => new(3, 2),
0 => new(0, 3),
c_BottomRightCorner => new(1, 3),
c_BottomLeftCorner | c_TopRightCorner => new(2, 3),
c_TopLeftCorner => new GridPosition(3, 3),
_ => throw new ArgumentOutOfRangeException(nameof(wallMask), wallMask, "Unsupported tile mask.")
};
return new(
tilePosition.X * c_TilemapTileSize,
tilePosition.Y * c_TilemapTileSize,
c_TilemapTileSize,
c_TilemapTileSize);
}
private int GetDualTileMask(int x, int y)
{
var mask = 0;
if (GetTerrainOrWall(x - 1, y - 1) == ECellTerrain.Floor)
mask |= c_TopLeftCorner;
if (GetTerrainOrWall(x, y - 1) == ECellTerrain.Floor)
mask |= c_TopRightCorner;
if (GetTerrainOrWall(x - 1, y) == ECellTerrain.Floor)
mask |= c_BottomLeftCorner;
if (GetTerrainOrWall(x, y) == ECellTerrain.Floor)
mask |= c_BottomRightCorner;
return mask;
}
private ECellTerrain GetTerrainOrWall(int x, int y)
{
var position = new GridPosition(x, y);
return m_Level.InBounds(position) ? m_Level.GetTerrain(position) : ECellTerrain.Wall;
}
private void DrawUnderground(CanvasDrawingSession drawing, CanvasLayout layout)
@@ -319,7 +403,7 @@ public sealed partial class MainWindow
drawing.DrawRectangle(rect, color, cell.State == EUndergroundState.Leaking ? 4 : 2);
if (cell.Amount > 0 || cell.Intensity > 0)
drawing.FillCircle((float)(rect.X + rect.Width / 2), (float)(rect.Y + rect.Height / 2), (float)Math.Max(2, rect.Width * 0.08), color);
drawing.FillCircle((float)(rect.X + (rect.Width / 2)), (float)(rect.Y + (rect.Height / 2)), (float)Math.Max(2, rect.Width * 0.08), color);
}
private void DrawSurface(CanvasDrawingSession drawing, CanvasLayout layout)
@@ -341,7 +425,7 @@ public sealed partial class MainWindow
if (amount <= 0)
return;
var alpha = (byte)Math.Clamp(40 + amount / Balancing.Current.MaxValue * 130, 40, 170);
var alpha = (byte)Math.Clamp(40 + (amount / Balancing.Current.MaxValue * 130), 40, 170);
drawing.FillRectangle(Inset(rect, inset), ColorHelper.FromArgb(alpha, color.R, color.G, color.B));
}
@@ -431,7 +515,7 @@ public sealed partial class MainWindow
{
var availableWidth = Math.Max(1, LevelCanvas.ActualWidth);
var availableHeight = Math.Max(1, LevelCanvas.ActualHeight);
return new((availableWidth - cellSize * m_Level.Width) / 2, (availableHeight - cellSize * m_Level.Height) / 2);
return new((availableWidth - (cellSize * m_Level.Width)) / 2, (availableHeight - (cellSize * m_Level.Height)) / 2);
}
private void ClampPan()
@@ -459,8 +543,8 @@ public sealed partial class MainWindow
m_Zoom = Math.Clamp(m_Zoom * zoomFactor, c_MinZoom, c_MaxZoom);
var newCellSize = GetBaseCellSize() * m_Zoom;
var originWithoutPan = GetCenteredOrigin(newCellSize);
m_PanX = point.X - originWithoutPan.X - cellX * newCellSize;
m_PanY = point.Y - originWithoutPan.Y - cellY * newCellSize;
m_PanX = point.X - originWithoutPan.X - (cellX * newCellSize);
m_PanY = point.Y - originWithoutPan.Y - (cellY * newCellSize);
ClampPan();
LevelCanvas.Invalidate();
}
@@ -478,6 +562,131 @@ public sealed partial class MainWindow
CellText.Text = m_SelectedCell is { } position && m_Level.InBounds(position) ? CellInspectionText(position) : "No cell selected.";
ForecastList.ItemsSource = m_Level.Forecasts.Select(forecast => new ForecastViewModel($"{forecast.Turns}: {forecast.Message}")).ToArray();
WorkflowText.Text = WorkflowInspectionText();
ReactorBindingText.Text = ReactorBindingInspectionText();
RuleEventText.Text = RuleEventInspectionText();
}
private void ApplySelectedTool(GridPosition position)
{
switch (m_SelectedTool.Tool)
{
case EEditorTool.Door:
ApplyDoorTool(position);
break;
case EEditorTool.Leak when m_SelectedTool.Carrier == ECarrierType.Electricity:
ApplyElectricityLeakTool(position);
break;
default:
ClearPendingEditorOperation();
m_Level = LevelEditor.Apply(m_Level, position, m_SelectedTool);
SelectReactorFromCell(position);
RefreshForecasts();
break;
}
}
private void ApplyDoorTool(GridPosition position)
{
if (!m_Level.IsFloor(position))
return;
if (m_PendingDoorCell is not { } pending)
{
m_PendingDoorCell = position;
m_Level = LevelEditor.Apply(m_Level, position, m_SelectedTool);
RefreshForecasts();
return;
}
m_Level = LevelEditor.SetDoorEdge(m_Level, pending, position);
m_PendingDoorCell = null;
RefreshForecasts();
}
private void ApplyElectricityLeakTool(GridPosition position)
{
if (m_Level.GetTerrain(position) == ECellTerrain.Wall)
{
m_PendingElectricityLeakCell = position;
return;
}
if (m_PendingElectricityLeakCell is { } undergroundPosition)
{
m_Level = LevelEditor.SetLeak(m_Level, undergroundPosition, position, ECarrierType.Electricity);
m_PendingElectricityLeakCell = null;
RefreshForecasts();
return;
}
m_Level = LevelEditor.Apply(m_Level, position, m_SelectedTool);
RefreshForecasts();
}
private void BindFuel_Click(object sender, RoutedEventArgs e)
{
BindSelectedConsumer(ECarrierType.Fuel);
}
private void BindCoolant_Click(object sender, RoutedEventArgs e)
{
BindSelectedConsumer(ECarrierType.Coolant);
}
private void BindElectricity_Click(object sender, RoutedEventArgs e)
{
BindSelectedConsumer(ECarrierType.Electricity);
}
private void AddWarningRule_Click(object sender, RoutedEventArgs e)
{
var turn = m_Level.Global.Turn + 1;
m_Level = LevelEditor.AddRuleEvent(m_Level, new() {
Phase = ERuleEventPhase.EndOfTurn,
ForecastText = $"Warning on turn {turn}",
Predicates = [new() { Kind = ERulePredicateKind.TurnAtLeast, Turn = turn }],
Effects = [new() { Kind = ERuleEffectKind.EmitWarning, Message = $"Authored warning on turn {turn}" }]
});
RefreshForecasts();
RefreshInspector();
}
private void AddLeakRule_Click(object sender, RoutedEventArgs e)
{
if (m_SelectedCell is not { } position || !TryGetAuthoredLeakCarrier(position, out var carrier))
return;
var turn = m_Level.Global.Turn + 1;
m_Level = LevelEditor.AddRuleEvent(m_Level, new() {
Phase = ERuleEventPhase.EndOfTurn,
ForecastText = $"{carrier} leak starts on turn {turn}",
Predicates = [new() { Kind = ERulePredicateKind.TurnAtLeast, Turn = turn }],
Effects = [new() { Kind = ERuleEffectKind.StartLeak, Position = position, AccessPosition = position, Carrier = carrier }]
});
RefreshForecasts();
RefreshInspector();
}
private void RemoveLastRule_Click(object sender, RoutedEventArgs e)
{
var ruleEvent = m_Level.RuleEvents.LastOrDefault();
if (ruleEvent is null)
return;
m_Level = LevelEditor.RemoveRuleEvent(m_Level, ruleEvent.Id);
RefreshForecasts();
RefreshInspector();
}
private void BindSelectedConsumer(ECarrierType carrier)
{
if (m_SelectedCell is not { } position || m_SelectedReactorId is not { } reactorId)
return;
m_Level = LevelEditor.BindReactorConsumer(m_Level, reactorId, carrier, position);
RefreshForecasts();
RefreshInspector();
}
private string CellInspectionText(GridPosition position)
@@ -497,6 +706,43 @@ public sealed partial class MainWindow
+ $"Blocks F/C/E: {surface.FuelBlockTurns}/{surface.CoolantBlockTurns}/{surface.ElectricityBlockTurns}";
}
private string WorkflowInspectionText()
{
if (m_PendingDoorCell is { } door)
return $"Door edge: select an adjacent floor for {door.X},{door.Y}.";
if (m_PendingElectricityLeakCell is { } leak)
return $"Electricity leak face: select adjacent floor access for {leak.X},{leak.Y}.";
return "No pending editor operation.";
}
private string ReactorBindingInspectionText()
{
var reactor = m_SelectedReactorId is { } reactorId ? m_Level.Reactors.FirstOrDefault(candidate => candidate.ReactorId == reactorId) : null;
if (reactor is null)
return "Select or place a reactor control.";
return $"Reactor {reactor.ReactorId}\n"
+ $"Fuel: {PositionText(reactor.FuelConsumerPosition)}\n"
+ $"Coolant: {PositionText(reactor.CoolantConsumerPosition)}\n"
+ $"Electric: {PositionText(reactor.ElectricityConsumerPosition)}";
}
private string RuleEventInspectionText()
{
if (m_Level.RuleEvents.Count == 0)
return "No authored rule events.";
var last = m_Level.RuleEvents[^1];
return $"{m_Level.RuleEvents.Count} authored. Last: {last.Id} ({last.Phase}).";
}
private static string PositionText(GridPosition position)
{
return $"{position.X},{position.Y}";
}
private static string UndergroundText(UndergroundCell cell)
{
return $"{cell.State} amount {Format(cell.Amount)} intensity {Format(cell.Intensity)}";
@@ -523,11 +769,11 @@ public sealed partial class MainWindow
level = level.SetProp(new(11, 4), new() { Type = EPropType.AllSeeingEyeTerminal });
level = level.SetProp(new(11, 6), new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.HeatShield });
level = level.SetUnderground(new(4, 5), ECarrierType.Coolant, new() { State = EUndergroundState.Leaking }) with {
Leaks = [new LeakState { Carrier = ECarrierType.Coolant, UndergroundPosition = new(4, 5), AccessPosition = new(4, 5) }],
Doors = [new DoorState { A = new(8, 5), B = new(9, 5), State = EDoorState.Closed }],
Leaks = [new() { Carrier = ECarrierType.Coolant, UndergroundPosition = new(4, 5), AccessPosition = new(4, 5) }],
Doors = [new() { A = new(8, 5), B = new(9, 5), State = EDoorState.Closed }],
Robot = new() { Position = new(10, 5) },
Reactors = [
new ReactorBinding {
new() {
ReactorId = 1,
ControlPosition = new(10, 5),
FuelConsumerPosition = new(5, 3),
@@ -555,28 +801,6 @@ public sealed partial class MainWindow
return level;
}
private static LevelState AutoBindReactors(LevelState level)
{
if (level.Reactors.Count == 0)
return level;
var fuel = FirstConsumer(level, ECarrierType.Fuel);
var coolant = FirstConsumer(level, ECarrierType.Coolant);
var electricity = FirstConsumer(level, ECarrierType.Electricity);
var reactors = level.Reactors.Select(reactor => reactor with {
FuelConsumerPosition = fuel ?? reactor.FuelConsumerPosition,
CoolantConsumerPosition = coolant ?? reactor.CoolantConsumerPosition,
ElectricityConsumerPosition = electricity ?? reactor.ElectricityConsumerPosition
})
.ToArray();
return level with { Reactors = reactors };
}
private static GridPosition? FirstConsumer(LevelState level, ECarrierType carrier)
{
return AllPositions(level).FirstOrDefault(position => level.GetProp(position) is { Type: EPropType.Consumer, Carrier: var propCarrier } && propCarrier == carrier);
}
private IEnumerable<GridPosition> AllPositions()
{
return AllPositions(m_Level);
@@ -594,12 +818,12 @@ public sealed partial class MainWindow
private static Rect Inset(Rect rect, double fraction)
{
var inset = rect.Width * fraction;
return new(rect.X + inset, rect.Y + inset, rect.Width - inset * 2, rect.Height - inset * 2);
return new(rect.X + inset, rect.Y + inset, rect.Width - (inset * 2), rect.Height - (inset * 2));
}
private static Point Center(Rect rect)
{
return new(rect.X + rect.Width / 2, rect.Y + rect.Height / 2);
return new(rect.X + (rect.Width / 2), rect.Y + (rect.Height / 2));
}
private static void DrawImage(CanvasDrawingSession drawing, CanvasBitmap? image, Rect rect, float opacity = 1)
@@ -613,7 +837,7 @@ public sealed partial class MainWindow
return prop.Type switch {
EPropType.Flow => CarrierColor(prop.Carrier),
EPropType.Consumer => ColorHelper.FromArgb(255, 93, 123, 170),
EPropType.TJunction or EPropType.CrossJunction => ColorHelper.FromArgb(255, 143, 111, 178),
EPropType.Junction => ColorHelper.FromArgb(255, 143, 111, 178),
EPropType.Door => ColorHelper.FromArgb(255, 187, 119, 55),
EPropType.AllSeeingEyeTerminal => ColorHelper.FromArgb(255, 85, 151, 156),
EPropType.RemedySupply => ColorHelper.FromArgb(255, 76, 145, 86),
@@ -637,8 +861,7 @@ public sealed partial class MainWindow
return prop.Type switch {
EPropType.Flow => $"{CarrierShort(prop.Carrier)} SRC",
EPropType.Consumer => $"{CarrierShort(prop.Carrier)} CON",
EPropType.TJunction => $"T {prop.TJunctionMode}",
EPropType.CrossJunction => $"X {prop.CrossJunctionMode}",
EPropType.Junction => $"J {prop.JunctionMode}",
EPropType.Door => "DOOR",
EPropType.AllSeeingEyeTerminal => "EYE",
EPropType.RemedySupply => RemedyShort(prop.RemedyType),
@@ -668,54 +891,126 @@ public sealed partial class MainWindow
};
}
private static string ToolLabel(EEditorTool tool)
private static IReadOnlyList<EditorToolViewModel> BuildEditorTools()
{
return tool switch {
EEditorTool.FuelUnderground => "Fuel Net",
EEditorTool.CoolantUnderground => "Coolant Net",
EEditorTool.ElectricityUnderground => "Electric Net",
EEditorTool.FuelFlow => "Fuel Source",
EEditorTool.CoolantFlow => "Coolant Source",
EEditorTool.ElectricityFlow => "Electric Source",
EEditorTool.FuelConsumer => "Fuel Consumer",
EEditorTool.CoolantConsumer => "Coolant Consumer",
EEditorTool.ElectricityConsumer => "Electric Consumer",
EEditorTool.AllSeeingEyeTerminal => "Eye Terminal",
EEditorTool.FuelRemedySupply => "Fuel Remedy",
EEditorTool.CoolantRemedySupply => "Coolant Remedy",
EEditorTool.ElectricityRemedySupply => "Electric Remedy",
EEditorTool.HeatRemedySupply => "Heat Shield",
EEditorTool.ReactorControl => "Reactor",
EEditorTool.FuelHazard => "Fuel Hazard",
EEditorTool.CoolantHazard => "Coolant Hazard",
EEditorTool.ElectricityHazard => "Electric Hazard",
_ => tool.ToString()
};
EditorToolViewModel Tool(EEditorTool tool, string label)
{
return new(new() { Tool = tool }, label) { IsSelected = tool == EEditorTool.Cursor };
}
EditorToolViewModel CarrierTool(EEditorTool tool, ECarrierType carrier, string label)
{
return new(new() { Tool = tool, Carrier = carrier }, label);
}
EditorToolViewModel RemedyTool(ERemedyType remedy, string label)
{
return new(new() { Tool = EEditorTool.RemedySupply, RemedyType = remedy }, label);
}
return [
Tool(EEditorTool.Cursor, "Cursor"),
Tool(EEditorTool.Floor, "Floor"),
Tool(EEditorTool.Wall, "Wall"),
CarrierTool(EEditorTool.Underground, ECarrierType.Fuel, "Fuel Net"),
CarrierTool(EEditorTool.Underground, ECarrierType.Coolant, "Coolant Net"),
CarrierTool(EEditorTool.Underground, ECarrierType.Electricity, "Electric Net"),
CarrierTool(EEditorTool.Flow, ECarrierType.Fuel, "Fuel Source"),
CarrierTool(EEditorTool.Flow, ECarrierType.Coolant, "Coolant Source"),
CarrierTool(EEditorTool.Flow, ECarrierType.Electricity, "Electric Source"),
CarrierTool(EEditorTool.Consumer, ECarrierType.Fuel, "Fuel Consumer"),
CarrierTool(EEditorTool.Consumer, ECarrierType.Coolant, "Coolant Consumer"),
CarrierTool(EEditorTool.Consumer, ECarrierType.Electricity, "Electric Consumer"),
Tool(EEditorTool.Junction, "Junction"),
Tool(EEditorTool.Door, "Door"),
Tool(EEditorTool.AllSeeingEyeTerminal, "Eye Terminal"),
RemedyTool(ERemedyType.FuelNeutralizer, "Fuel Remedy"),
RemedyTool(ERemedyType.CoolantNeutralizer, "Coolant Remedy"),
RemedyTool(ERemedyType.ElectricityNeutralizer, "Electric Remedy"),
RemedyTool(ERemedyType.HeatShield, "Heat Shield"),
Tool(EEditorTool.ReactorControl, "Reactor"),
CarrierTool(EEditorTool.Leak, ECarrierType.Fuel, "Fuel Leak"),
CarrierTool(EEditorTool.Leak, ECarrierType.Coolant, "Coolant Leak"),
CarrierTool(EEditorTool.Leak, ECarrierType.Electricity, "Electric Leak"),
CarrierTool(EEditorTool.SurfaceHazard, ECarrierType.Fuel, "Fuel Hazard"),
CarrierTool(EEditorTool.SurfaceHazard, ECarrierType.Coolant, "Coolant Hazard"),
CarrierTool(EEditorTool.SurfaceHazard, ECarrierType.Electricity, "Electric Hazard"),
Tool(EEditorTool.Heat, "Heat"),
Tool(EEditorTool.Robot, "Robot")
];
}
private void SelectReactorFromCell(GridPosition position)
{
var prop = m_Level.GetProp(position);
if (prop is { Type: EPropType.ReactorControl, ReactorId: > 0 })
m_SelectedReactorId = prop.ReactorId;
}
private bool TryGetAuthoredLeakCarrier(GridPosition position, out ECarrierType carrier)
{
if (!m_Level.IsFloor(position))
{
carrier = default;
return false;
}
foreach (var candidate in Enum.GetValues<ECarrierType>())
{
if (m_Level.GetUnderground(position, candidate).IsPresent)
{
carrier = candidate;
return true;
}
}
carrier = default;
return false;
}
private void RefreshForecasts()
{
m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) };
}
private void ClearPendingEditorOperation()
{
m_PendingDoorCell = null;
m_PendingElectricityLeakCell = null;
}
private const double c_MinZoom = 0.5;
private const double c_MaxZoom = 4;
private const double c_ZoomStep = 1.15;
private const double c_ClickPixelThreshold = 10;
private const int c_TilemapTileSize = 512;
private const int c_TopLeftCorner = 1;
private const int c_TopRightCorner = 2;
private const int c_BottomLeftCorner = 4;
private const int c_BottomRightCorner = 8;
private const int c_AllCorners = c_TopLeftCorner | c_TopRightCorner | c_BottomLeftCorner | c_BottomRightCorner;
private static readonly Color c_FuelColor = ColorHelper.FromArgb(255, 220, 170, 68);
private static readonly Color c_CoolantColor = ColorHelper.FromArgb(255, 57, 174, 196);
private static readonly Color c_ElectricityColor = ColorHelper.FromArgb(255, 236, 226, 82);
private readonly IReadOnlyList<EditorToolViewModel> m_EditorTools = [];
private readonly SimulationEngine m_Simulation = new();
private StorageFile? m_CurrentFile;
private LevelState m_Level;
private IReadOnlyList<EditorToolViewModel> m_EditorTools = [];
private bool m_LeftPointerDown;
private bool m_DragExceededClickThreshold;
private Point m_LeftPointerDownPoint;
private CanvasBitmap? m_HeatSprite;
private Point m_LastPanPoint;
private GridPosition? m_SelectedCell;
private EEditorTool m_SelectedTool = EEditorTool.Cursor;
private double m_Zoom = 1;
private CanvasBitmap? m_LeakSprite;
private bool m_LeftPointerDown;
private Point m_LeftPointerDownPoint;
private LevelState m_Level;
private double m_PanX;
private double m_PanY;
private CanvasBitmap? m_TerrainTilemap;
private GridPosition? m_PendingDoorCell;
private GridPosition? m_PendingElectricityLeakCell;
private CanvasBitmap? m_RobotSprite;
private CanvasBitmap? m_LeakSprite;
private CanvasBitmap? m_HeatSprite;
private GridPosition? m_SelectedCell;
private int? m_SelectedReactorId = 1;
private EditorToolCommand m_SelectedTool = new() { Tool = EEditorTool.Cursor };
private CanvasBitmap? m_TerrainTilemap;
private double m_Zoom = 1;
}

View File

@@ -17,13 +17,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Graphics.Win2D" Version="1.4.0" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
<PackageReference Include="Microsoft.Graphics.Win2D" Version="1.4.0"/>
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839"/>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ReactorMaintenance.Simulation\ReactorMaintenance.Simulation.csproj" />
<ProjectReference Include="..\ReactorMaintenance.Simulation\ReactorMaintenance.Simulation.csproj"/>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,76 @@
namespace ReactorMaintenance.Simulation.Tests;
public sealed class LevelEditorTests
{
[Fact]
public void DoorToolRequiresExplicitAdjacentEdgeSelection()
{
var level = LevelState.Create("Door editor", 6, 6);
var withDoorProp = LevelEditor.Apply(level, new(2, 2), new() { Tool = EEditorTool.Door });
var withDoorEdge = LevelEditor.SetDoorEdge(withDoorProp, new(2, 2), new(3, 2));
var rejected = LevelEditor.SetDoorEdge(withDoorEdge, new(2, 2), new(4, 2));
Assert.Equal(EPropType.Door, withDoorProp.GetProp(new(2, 2)).Type);
Assert.Empty(withDoorProp.Doors);
Assert.Single(withDoorEdge.Doors);
Assert.Equal(withDoorEdge.Doors, rejected.Doors);
}
[Fact]
public void ElectricityLeakUsesAuthoredWallAccessFace()
{
var level = LevelState.Create("Electricity leak editor", 6, 6);
level = level.SetTerrain(new(2, 2), ECellTerrain.Wall);
var next = LevelEditor.SetLeak(level, new(2, 2), new(2, 3), ECarrierType.Electricity);
var rejected = LevelEditor.SetLeak(next, new(2, 2), new(4, 4), ECarrierType.Electricity);
Assert.Single(next.Leaks);
Assert.Equal(new(2, 3), next.Leaks[0].AccessPosition);
Assert.Equal(EUndergroundState.Leaking, next.GetUnderground(new(2, 2), ECarrierType.Electricity).State);
Assert.Equal(next.Leaks, rejected.Leaks);
}
[Fact]
public void ReactorBindingUpdatesOnlyMatchingCarrierConsumer()
{
var level = LevelState.Create("Binding editor", 8, 6);
level = level.SetProp(new(1, 1), new() { Type = EPropType.ReactorControl, ReactorId = 1 });
level = level.SetProp(new(2, 1), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Fuel });
level = level.SetProp(new(3, 1), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Coolant }) with {
Reactors = [
new() {
ReactorId = 1,
ControlPosition = new(1, 1),
FuelConsumerPosition = new(1, 1),
CoolantConsumerPosition = new(1, 1),
ElectricityConsumerPosition = new(1, 1)
}
]
};
var bound = LevelEditor.BindReactorConsumer(level, 1, ECarrierType.Fuel, new(2, 1));
var rejected = LevelEditor.BindReactorConsumer(bound, 1, ECarrierType.Fuel, new(3, 1));
Assert.Equal(new(2, 1), bound.Reactors[0].FuelConsumerPosition);
Assert.Equal(bound.Reactors[0].FuelConsumerPosition, rejected.Reactors[0].FuelConsumerPosition);
}
[Fact]
public void RuleEventEditorAssignsStableIdsAndCanRemoveEvents()
{
var level = LevelState.Create("Rule editor", 6, 6);
var withRule = LevelEditor.AddRuleEvent(level, new() {
Phase = ERuleEventPhase.EndOfTurn,
Predicates = [new() { Kind = ERulePredicateKind.TurnAtLeast, Turn = 1 }],
Effects = [new() { Kind = ERuleEffectKind.EmitWarning, Message = "authored" }]
});
var removed = LevelEditor.RemoveRuleEvent(withRule, "rule-1");
Assert.Single(withRule.RuleEvents);
Assert.Equal("rule-1", withRule.RuleEvents[0].Id);
Assert.Empty(removed.RuleEvents);
}
}

View File

@@ -10,18 +10,18 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0"/>
<PackageReference Include="xunit" Version="2.5.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ReactorMaintenance.Simulation\ReactorMaintenance.Simulation.csproj" />
<ProjectReference Include="..\..\src\ReactorMaintenance.Simulation\ReactorMaintenance.Simulation.csproj"/>
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
namespace ReactorMaintenance.Simulation.Tests;
namespace ReactorMaintenance.Simulation.Tests;
public sealed class SimulationEngineTests
{
@@ -34,7 +34,7 @@ public sealed class SimulationEngineTests
{
var level = LevelState.Create("Leak", 6, 6);
level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, new() { State = EUndergroundState.Leaking, Amount = 5, Intensity = 5 }) with {
Leaks = [new LeakState { Carrier = ECarrierType.Fuel, UndergroundPosition = new(2, 2), AccessPosition = new(2, 2) }]
Leaks = [new() { Carrier = ECarrierType.Fuel, UndergroundPosition = new(2, 2), AccessPosition = new(2, 2) }]
};
level = level.SetProp(new(2, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
@@ -50,7 +50,7 @@ public sealed class SimulationEngineTests
level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, new() { State = EUndergroundState.Leaking, Amount = 5, Intensity = 5 });
level = level.SetSurface(new(2, 2), new() { Fuel = 5 }) with {
Robot = new() { Position = new(2, 2), FuelNeutralizers = 1 },
Leaks = [new LeakState { Carrier = ECarrierType.Fuel, UndergroundPosition = new(2, 2), AccessPosition = new(2, 2) }]
Leaks = [new() { Carrier = ECarrierType.Fuel, UndergroundPosition = new(2, 2), AccessPosition = new(2, 2) }]
};
var next = m_Engine.InteractLeak(level, ECarrierType.Fuel, true);
@@ -65,7 +65,7 @@ public sealed class SimulationEngineTests
{
var level = LevelState.Create("Door", 6, 6);
level = level.SetSurface(new(2, 2), new() { Heat = 8 }) with {
Doors = [new DoorState { A = new(2, 2), B = new(3, 2), State = EDoorState.Closed }]
Doors = [new() { A = new(2, 2), B = new(3, 2), State = EDoorState.Closed }]
};
var next = m_Engine.AdvanceTurn(level);
@@ -86,6 +86,69 @@ public sealed class SimulationEngineTests
Assert.NotEqual(ELevelState.Lost, next.Global.LevelState);
}
[Fact]
public void JunctionRatioSplitsFlowAcrossInferredOutgoingBranches()
{
var level = BuildJunctionLevel(2);
var next = m_Engine.AdvanceTurn(level);
Assert.True(next.GetUnderground(new(2, 2), ECarrierType.Fuel).Amount > 0);
Assert.Equal(next.GetUnderground(new(2, 2), ECarrierType.Fuel).Amount, next.GetUnderground(new(3, 3), ECarrierType.Fuel).Amount);
Assert.True(next.GetUnderground(new(2, 2), ECarrierType.Fuel).Amount < next.GetUnderground(new(2, 3), ECarrierType.Fuel).Amount);
}
[Fact]
public void JunctionZeroWeightBranchReceivesNoIntentionalOutflow()
{
var level = BuildJunctionLevel(0);
var next = m_Engine.AdvanceTurn(level);
Assert.Equal(0, next.GetUnderground(new(2, 2), ECarrierType.Fuel).Amount);
Assert.True(next.GetUnderground(new(3, 3), ECarrierType.Fuel).Amount > 0);
}
[Fact]
public void ValidatorRejectsAmbiguousJunctionSourceBranches()
{
var level = BuildJunctionLevel(2);
level = level.SetProp(new(3, 3), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
var report = new LevelValidator().Validate(level);
Assert.False(report.IsValid);
Assert.Contains(report.Errors, error => error.Message.Contains("Ambiguous junction flow", StringComparison.Ordinal));
}
[Fact]
public void ValidatorRejectsJunctionWithoutTwoOrThreeOutflows()
{
var level = BuildJunctionLevel(2);
level = level.SetUnderground(new(3, 3), ECarrierType.Fuel, new());
var report = new LevelValidator().Validate(level);
Assert.False(report.IsValid);
Assert.Contains(report.Errors, error => error.Message.Contains("one incoming branch and two or three outgoing branches", StringComparison.Ordinal));
}
[Fact]
public void NonJunctionCellsAcceptBestFlowFromMultipleSourcePaths()
{
var level = LevelState.Create("Best path", 7, 7);
level = AddHorizontalUnderground(level, ECarrierType.Fuel, 1, 5, 3);
level = level.SetProp(new(1, 3), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
level = level.SetProp(new(5, 3), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
level = level.SetProp(new(3, 3), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Fuel });
var report = new LevelValidator().Validate(level);
var next = m_Engine.AdvanceTurn(level);
Assert.True(report.IsValid);
Assert.Equal(EConsumerServiceState.Producing, next.GetProp(new(3, 3)).ServiceState);
}
[Fact]
public void RobotLosesOnUnsafeElementHazard()
{
@@ -104,11 +167,11 @@ public sealed class SimulationEngineTests
{
var level = LevelState.Create("Rule", 6, 6) with {
RuleEvents = [
new RuleEventState {
new() {
Phase = ERuleEventPhase.EndOfTurn,
ForecastText = "containment failure",
Predicates = [new RulePredicate { Kind = ERulePredicateKind.TurnAtLeast, Turn = 0 }],
Effects = [new RuleEffect { Kind = ERuleEffectKind.MarkTerminalLoss, Message = "CONTAINMENT FAILURE" }]
Predicates = [new() { Kind = ERulePredicateKind.TurnAtLeast, Turn = 0 }],
Effects = [new() { Kind = ERuleEffectKind.MarkTerminalLoss, Message = "CONTAINMENT FAILURE" }]
}
]
};
@@ -119,6 +182,141 @@ public sealed class SimulationEngineTests
Assert.Contains(forecasts, forecast => forecast.Kind == EForecastKind.TerminalLoss);
}
[Fact]
public void RuleEventCanTriggerFromNetworkBand()
{
var level = LevelState.Create("Network rule", 6, 6);
level = AddLine(level, ECarrierType.Fuel, new(2, 2), new(3, 2));
level = level.SetProp(new(2, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel }) with {
RuleEvents = [
new() {
Phase = ERuleEventPhase.EndOfTurn,
Predicates = [new() { Kind = ERulePredicateKind.NetworkBandAt, Position = new(3, 2), Carrier = ECarrierType.Fuel, NetworkValue = ENetworkValueKind.Amount, Band = EBand.Critical }],
Effects = [new() { Kind = ERuleEffectKind.EmitWarning, Message = "fuel pressure high" }]
}
]
};
var next = m_Engine.AdvanceTurn(level);
Assert.Contains("fuel pressure high", next.Global.Warnings);
}
[Fact]
public void RuleEventCanTriggerFromReactorReadiness()
{
var level = BuildReadyLevel() with {
RuleEvents = [
new() {
Phase = ERuleEventPhase.EndOfTurn,
Predicates = [new() { Kind = ERulePredicateKind.ReactorReadyIs, ReactorId = 1, BoolValue = true }],
Effects = [new() { Kind = ERuleEffectKind.EmitWarning, Message = "reactor ready rule" }]
}
]
};
var next = m_Engine.AdvanceTurn(level);
Assert.Contains("reactor ready rule", next.Global.Warnings);
}
[Fact]
public void RuleEventCanTriggerFromRobotInventory()
{
var level = LevelState.Create("Inventory rule", 6, 6) with {
Robot = new() { Position = new(1, 1), FuelNeutralizers = 1 },
RuleEvents = [
new() {
Phase = ERuleEventPhase.StartOfSimulation,
Predicates = [new() { Kind = ERulePredicateKind.RobotInventoryAtLeast, Remedy = ERemedyType.FuelNeutralizer, InventoryCount = 1 }],
Effects = [new() { Kind = ERuleEffectKind.EmitWarning, Message = "fuel kit detected" }]
}
]
};
var next = m_Engine.AdvanceTurn(level);
Assert.Contains("fuel kit detected", next.Global.Warnings);
}
[Fact]
public void RuleEventCanRemoveHazardsHeatAndInventory()
{
var level = LevelState.Create("Remove rule", 6, 6);
level = level.SetSurface(new(2, 2), new() { Fuel = 5, Heat = 5 }) with {
Robot = new() { Position = new(1, 1), FuelNeutralizers = 2 },
Doors = [
new() { A = new(2, 2), B = new(1, 2), State = EDoorState.Closed },
new() { A = new(2, 2), B = new(3, 2), State = EDoorState.Closed },
new() { A = new(2, 2), B = new(2, 1), State = EDoorState.Closed },
new() { A = new(2, 2), B = new(2, 3), State = EDoorState.Closed }
],
RuleEvents = [
new() {
Phase = ERuleEventPhase.StartOfSimulation,
Predicates = [new() { Kind = ERulePredicateKind.TurnAtLeast, Turn = 0 }],
Effects = [
new() { Kind = ERuleEffectKind.RemoveSurfaceHazard, Position = new(2, 2), Carrier = ECarrierType.Fuel, Amount = 2 },
new() { Kind = ERuleEffectKind.RemoveHeat, Position = new(2, 2), Amount = 3 },
new() { Kind = ERuleEffectKind.RemoveInventory, Remedy = ERemedyType.FuelNeutralizer, Amount = 1 }
]
}
]
};
var next = m_Engine.AdvanceTurn(level);
Assert.Equal(3, next.GetSurface(new(2, 2)).Fuel);
Assert.Equal(2, next.GetSurface(new(2, 2)).Heat);
Assert.Equal(1, next.Robot.FuelNeutralizers);
}
[Fact]
public void RuleEventStartLeakUsesAuthoredElectricityAccessFace()
{
var level = LevelState.Create("Electricity leak rule", 6, 6);
level = level.SetTerrain(new(2, 2), ECellTerrain.Wall);
level = level.SetUnderground(new(2, 2), ECarrierType.Electricity, new() { State = EUndergroundState.Intact }) with {
RuleEvents = [
new() {
Phase = ERuleEventPhase.StartOfSimulation,
Predicates = [new() { Kind = ERulePredicateKind.TurnAtLeast, Turn = 0 }],
Effects = [new() { Kind = ERuleEffectKind.StartLeak, Position = new(2, 2), AccessPosition = new(2, 3), Carrier = ECarrierType.Electricity }]
}
]
};
var next = m_Engine.AdvanceTurn(level);
Assert.Single(next.Leaks);
Assert.Equal(new(2, 3), next.Leaks[0].AccessPosition);
Assert.True(next.GetSurface(new(2, 3)).Electricity > 0);
}
[Fact]
public void ValidatorRejectsInvalidRuleTargets()
{
var level = LevelState.Create("Invalid rules", 6, 6);
level = level.SetTerrain(new(2, 2), ECellTerrain.Wall) with {
RuleEvents = [
new() {
Predicates = [new() { Kind = ERulePredicateKind.PropStateAt, Position = new(1, 1) }],
Effects = [
new() { Kind = ERuleEffectKind.AddSurfaceHazard, Position = new(2, 2), Carrier = ECarrierType.Fuel, Amount = 1 },
new() { Kind = ERuleEffectKind.RepairNetworkCell, Position = new(3, 3), Carrier = ECarrierType.Coolant }
]
}
]
};
var report = new LevelValidator().Validate(level);
Assert.False(report.IsValid);
Assert.Contains(report.Errors, error => error.Message.Contains("Rule prop predicate", StringComparison.Ordinal));
Assert.Contains(report.Errors, error => error.Message.Contains("Rule surface effect", StringComparison.Ordinal));
Assert.Contains(report.Errors, error => error.Message.Contains("Rule network effect", StringComparison.Ordinal));
}
[Fact]
public void ValidatorRejectsWallHazardsAndInvalidReactorBinding()
{
@@ -126,7 +324,7 @@ public sealed class SimulationEngineTests
level = level.SetTerrain(new(2, 2), ECellTerrain.Wall);
level = level with {
Surface = level.Surface.ToArray(),
Reactors = [new ReactorBinding { ControlPosition = new(3, 3), FuelConsumerPosition = new(1, 1), CoolantConsumerPosition = new(1, 1), ElectricityConsumerPosition = new(1, 1) }]
Reactors = [new() { ControlPosition = new(3, 3), FuelConsumerPosition = new(1, 1), CoolantConsumerPosition = new(1, 1), ElectricityConsumerPosition = new(1, 1) }]
};
level.Surface[level.Index(new(2, 2))] = new() { Heat = 1 };
@@ -150,6 +348,33 @@ public sealed class SimulationEngineTests
Assert.Equal(EPropType.Flow, loaded.GetProp(new(2, 2)).Type);
}
[Fact]
public void LevelSerializationRoundTripsRuleEventsDoorsAndElectricityLeakFaces()
{
var level = BuildReadyLevel();
level = level.SetTerrain(new(6, 4), ECellTerrain.Wall);
level = level.SetUnderground(new(6, 4), ECarrierType.Electricity, new() { State = EUndergroundState.Leaking }) with {
Doors = [new() { A = new(5, 3), B = new(5, 4), State = EDoorState.Closed }],
Leaks = [new() { Carrier = ECarrierType.Electricity, UndergroundPosition = new(6, 4), AccessPosition = new(6, 3) }],
RuleEvents = [
new() {
Id = "authored",
Phase = ERuleEventPhase.EndOfTurn,
Predicates = [new() { Kind = ERulePredicateKind.TurnAtLeast, Turn = 2 }],
Effects = [new() { Kind = ERuleEffectKind.EmitWarning, Message = "serialized" }]
}
]
};
var loaded = LevelSerializer.Deserialize(LevelSerializer.Serialize(level));
Assert.Single(loaded.Doors);
Assert.Single(loaded.Leaks);
Assert.Single(loaded.RuleEvents);
Assert.Equal(new(6, 3), loaded.Leaks[0].AccessPosition);
Assert.Equal("authored", loaded.RuleEvents[0].Id);
}
[Fact]
public void LevelSerializationRejectsOldSchema()
{
@@ -181,7 +406,7 @@ public sealed class SimulationEngineTests
return level with {
Robot = new() { Position = new(5, 3) },
Reactors = [
new ReactorBinding {
new() {
ReactorId = 1,
ControlPosition = new(5, 3),
FuelConsumerPosition = new(3, 2),
@@ -199,5 +424,24 @@ public sealed class SimulationEngineTests
return level;
}
private static LevelState BuildJunctionLevel(int mode)
{
var level = LevelState.Create("Junction", 6, 6);
level = level.SetUnderground(new(1, 3), ECarrierType.Fuel, new() { State = EUndergroundState.Intact });
level = level.SetUnderground(new(2, 3), ECarrierType.Fuel, new() { State = EUndergroundState.Intact });
level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, new() { State = EUndergroundState.Intact });
level = level.SetUnderground(new(3, 3), ECarrierType.Fuel, new() { State = EUndergroundState.Intact });
level = level.SetProp(new(1, 3), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
return level.SetProp(new(2, 3), new() { Type = EPropType.Junction, Carrier = ECarrierType.Fuel, JunctionMode = mode });
}
private static LevelState AddHorizontalUnderground(LevelState level, ECarrierType carrier, int startX, int endX, int y)
{
for (var x = startX; x <= endX; x++)
level = level.SetUnderground(new(x, y), carrier, new() { State = EUndergroundState.Intact });
return level;
}
private readonly SimulationEngine m_Engine = new();
}