Add branch-aware junction flow
This commit is contained in:
13
TASKS.md
13
TASKS.md
@@ -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.
|
||||
|
||||
59
src/ReactorMaintenance.Simulation/JunctionFlow.cs
Normal file
59
src/ReactorMaintenance.Simulation/JunctionFlow.cs
Normal 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.")
|
||||
};
|
||||
}
|
||||
}
|
||||
98
src/ReactorMaintenance.Simulation/JunctionFlowAnalyzer.cs
Normal file
98
src/ReactorMaintenance.Simulation/JunctionFlowAnalyzer.cs
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user