Add branch-aware junction flow

This commit is contained in:
2026-05-10 17:29:19 +02:00
parent b232c0319f
commit cb28eee1dd
6 changed files with 251 additions and 54 deletions

View File

@@ -1,4 +1,4 @@
# Reactor Maintenance Rewrite Tasks
# Reactor Maintenance Rewrite Tasks
## Current State
@@ -33,14 +33,21 @@
- 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.
- Committed the Win2D editor rewrite slice.
- Added branch-aware junction flow analysis shared by validation and simulation propagation.
- Junction validation now rejects malformed branch counts and ambiguous source-side branches.
- Junction propagation now applies deterministic T-junction and cross-junction ratio weights only to inferred outgoing branches.
- Added tests for T-junction ratio splits, zero-weight branches, ambiguous junction validation, and best-path flow into non-junction cells.
- Verified `dotnet test tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj` passes: 15 passed.
- Ran `jb cleanupcode --build=False ...` and `python D:\Code\crlf.py ...` for touched C# files after the junction slice.
## Current Work
- Commit the Win2D editor rewrite slice.
- Await review and next task selection on `design-rewrite`.
## 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.
1. Expand simulation fidelity where the first slice is intentionally simplified: 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.

View File

@@ -0,0 +1,59 @@
namespace ReactorMaintenance.Simulation;
public sealed record JunctionFlow
{
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;
public float WeightFor(GridPosition outgoingBranch)
{
var index = IndexOfOutgoingBranch(outgoingBranch);
if (index < 0)
return 0;
var weights = Prop.Type == EPropType.TJunction
? TJunctionWeights(Prop.TJunctionMode)
: CrossJunctionWeights(Prop.CrossJunctionMode);
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;
}
private static float[] TJunctionWeights(ETJunctionMode mode)
{
return mode switch {
ETJunctionMode.ZeroFour => [0, 1],
ETJunctionMode.OneThree => [0.25f, 0.75f],
ETJunctionMode.TwoTwo => [0.5f, 0.5f],
ETJunctionMode.ThreeOne => [0.75f, 0.25f],
ETJunctionMode.FourZero => [1, 0],
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported T-junction mode.")
};
}
private static float[] CrossJunctionWeights(ECrossJunctionMode mode)
{
return mode switch {
ECrossJunctionMode.ZeroThreeThree => [0, 0.5f, 0.5f],
ECrossJunctionMode.ThreeZeroThree => [0.5f, 0, 0.5f],
ECrossJunctionMode.ThreeThreeZero => [0.5f, 0.5f, 0],
ECrossJunctionMode.TwoTwoTwo => [1f / 3f, 1f / 3f, 1f / 3f],
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported cross-junction mode.")
};
}
}

View File

@@ -0,0 +1,98 @@
namespace ReactorMaintenance.Simulation;
public static class JunctionFlowAnalyzer
{
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 is not (EPropType.TJunction or EPropType.CrossJunction))
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>();
var expectedBranches = prop.Type == EPropType.TJunction ? 3 : 4;
if (carriers.Length == 1 && branches.Length != expectedBranches)
errors.Add($"{prop.Type} must have exactly {expectedBranches} connected underground 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;
}
private sealed record SourceBranch(GridPosition Position, int? Distance);
}

View File

@@ -1,4 +1,4 @@
namespace ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Simulation;
public sealed class LevelValidator
{
@@ -113,20 +113,8 @@ 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)

View File

@@ -1,4 +1,4 @@
namespace ReactorMaintenance.Simulation;
namespace ReactorMaintenance.Simulation;
public sealed class SimulationEngine
{
@@ -156,9 +156,10 @@ public sealed class SimulationEngine
{
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();
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);
ApplySourceFlow(level, layer, source, carrier, junctions);
return carrier switch {
ECarrierType.Fuel => level with { Fuel = layer },
@@ -168,7 +169,7 @@ public sealed class SimulationEngine
};
}
private static void ApplySourceFlow(LevelState level, UndergroundCell[] layer, GridPosition source, ECarrierType 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>();
@@ -191,7 +192,7 @@ public sealed class SimulationEngine
if (!level.GetUnderground(next, carrier).CarriesFlow)
continue;
var weights = BranchWeights(level, current.Position, next);
var weights = BranchWeights(current.Position, next, junctions);
var amountFactor = current.AmountFactor * weights.Amount;
var intensityFactor = current.IntensityFactor * weights.Intensity;
if (amountFactor <= 0 || intensityFactor <= 0)
@@ -206,38 +207,12 @@ public sealed class SimulationEngine
}
}
private static (float Amount, float Intensity) BranchWeights(LevelState level, GridPosition from, GridPosition to)
private static (float Amount, float Intensity) BranchWeights(GridPosition from, GridPosition to, IReadOnlyDictionary<GridPosition, JunctionFlow> junctions)
{
var prop = level.GetProp(from);
return prop.Type switch {
EPropType.TJunction => TJunctionWeights(prop.TJunctionMode),
EPropType.CrossJunction => CrossJunctionWeights(prop.CrossJunctionMode),
_ => (1, 1)
};
}
if (!junctions.TryGetValue(from, out var junction))
return (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.")
};
var weight = junction.WeightFor(to);
return (weight, weight);
}

View File

@@ -1,4 +1,4 @@
namespace ReactorMaintenance.Simulation.Tests;
namespace ReactorMaintenance.Simulation.Tests;
public sealed class SimulationEngineTests
{
@@ -86,6 +86,57 @@ public sealed class SimulationEngineTests
Assert.NotEqual(ELevelState.Lost, next.Global.LevelState);
}
[Fact]
public void TJunctionRatioSplitsFlowAcrossInferredOutgoingBranches()
{
var level = BuildTJunctionLevel(ETJunctionMode.TwoTwo);
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 TJunctionZeroWeightBranchReceivesNoIntentionalOutflow()
{
var level = BuildTJunctionLevel(ETJunctionMode.ZeroFour);
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 = BuildTJunctionLevel(ETJunctionMode.TwoTwo);
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 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()
{
@@ -199,5 +250,24 @@ public sealed class SimulationEngineTests
return level;
}
private static LevelState BuildTJunctionLevel(ETJunctionMode mode)
{
var level = LevelState.Create("T 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.TJunction, Carrier = ECarrierType.Fuel, TJunctionMode = 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();
}