diff --git a/README.md b/README.md
index 882d094..4daa3f0 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,28 @@
-# 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.
-
-## Commands
-
-```powershell
-dotnet test tests\ReactorMaintenance.Simulation.Tests\ReactorMaintenance.Simulation.Tests.csproj
-dotnet build src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64 -p:EnableWindowsTargeting=true
-dotnet run --project src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64
-```
-
-The WinUI/XAML compiler is Windows-specific. On Linux, the simulation tests run normally, but the Win2D app build must be verified in a Windows-capable environment.
+# 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, 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
+
+```powershell
+dotnet test tests\ReactorMaintenance.Simulation.Tests\ReactorMaintenance.Simulation.Tests.csproj
+dotnet build src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64 -p:EnableWindowsTargeting=true
+dotnet run --project src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64
+```
+
+The WinUI/XAML compiler is Windows-specific. On Linux, the simulation tests run normally, but the Win2D app build must be verified in a Windows-capable environment.
diff --git a/TASKS.md b/TASKS.md
index 71494aa..3a6e186 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -56,17 +56,17 @@
- Removed the temporary `SimulationCoreSystem` catch-all and made `SimulationEngine` the real orchestration point.
- Split simulation behavior into purposeful systems for player actions, network propagation, consumers, leaks, surface interactions, robot safety, reactors, rule events, and forecasts.
- Kept `Systems` limited to behavior-owning systems; shared traversal, band, and carrier math helpers live at the simulation root.
+- Replaced carrier-specific editor enum entries with parameterized editor commands.
+- Added explicit editor workflows for door edge selection, electricity wall leak access-face selection, reactor consumer binding, and basic rule event authoring.
+- Added editor operation tests plus broader junction validation and serialization round-trip coverage.
+- Verified `dotnet test tests\ReactorMaintenance.Simulation.Tests\ReactorMaintenance.Simulation.Tests.csproj` passes: 27 passed.
+- Verified `dotnet build src\ReactorMaintenance.Win2D\ReactorMaintenance.Win2D.csproj -p:Platform=x64 -p:EnableWindowsTargeting=true` succeeds on Windows.
+- Ran `jb cleanupcode --build=False ...` and `python D:\Code\crlf.py ...` for touched files in the final rewrite slice.
## Current Work
-- Await review and next task selection on `design-rewrite`.
+- Rewrite task list completed on `design-rewrite`.
## Future Work
-1. Continue reducing helper surface where classes no longer carry enough responsibility after future changes.
-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, 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.
+No known rewrite tasks remain.
diff --git a/docs/design.md b/docs/design.md
index 663fc68..6ee6e0d 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -157,6 +157,7 @@ A junction prop must be on a floor cell whose coordinate has exactly one undergr
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.
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
@@ -400,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
+- reactor controls and explicit reactor consumer bindings
- junction props and balance-defined ratio mode index
-- door props and door edges
+- 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
diff --git a/src/ReactorMaintenance.Simulation/EEditorTool.cs b/src/ReactorMaintenance.Simulation/EEditorTool.cs
new file mode 100644
index 0000000..cd1d18d
--- /dev/null
+++ b/src/ReactorMaintenance.Simulation/EEditorTool.cs
@@ -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
+}
\ No newline at end of file
diff --git a/src/ReactorMaintenance.Simulation/EditorToolCommand.cs b/src/ReactorMaintenance.Simulation/EditorToolCommand.cs
new file mode 100644
index 0000000..a5008fd
--- /dev/null
+++ b/src/ReactorMaintenance.Simulation/EditorToolCommand.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/ReactorMaintenance.Simulation/LevelEditor.cs b/src/ReactorMaintenance.Simulation/LevelEditor.cs
index c75468f..c156749 100644
--- a/src/ReactorMaintenance.Simulation/LevelEditor.cs
+++ b/src/ReactorMaintenance.Simulation/LevelEditor.cs
@@ -1,91 +1,92 @@
namespace ReactorMaintenance.Simulation;
-public enum EEditorTool
-{
- Cursor,
- Floor,
- Wall,
- FuelUnderground,
- CoolantUnderground,
- ElectricityUnderground,
- FuelFlow,
- CoolantFlow,
- ElectricityFlow,
- FuelConsumer,
- CoolantConsumer,
- ElectricityConsumer,
- Junction,
- Door,
- AllSeeingEyeTerminal,
- FuelRemedySupply,
- CoolantRemedySupply,
- ElectricityRemedySupply,
- HeatRemedySupply,
- ReactorControl,
- FuelLeak,
- CoolantLeak,
- ElectricityLeak,
- FuelHazard,
- CoolantHazard,
- ElectricityHazard,
- Heat,
- Robot
-}
-
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.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 => SetDoor(level, position),
+ 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 });
@@ -96,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() { A = position, B = neighbor }]
- };
- }
-
private static LevelState SetReactorControl(LevelState level, GridPosition position)
{
if (!level.IsFloor(position))
@@ -141,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;
+ return SetLeak(level, position, position, carrier);
+ }
- if (accessPosition is null || !level.IsFloor(accessPosition))
- return level;
+ private static bool SameDoorEdge(DoorState door, GridPosition a, GridPosition b)
+ {
+ return door.A == a && door.B == b || door.A == b && door.B == a;
+ }
- var next = level.SetUnderground(position, carrier, new() { State = EUndergroundState.Leaking });
- return next with {
- Leaks = [
- .. next.Leaks,
- new() {
- Carrier = carrier,
- UndergroundPosition = position,
- AccessPosition = accessPosition
- }
- ]
+ 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}";
+ }
}
\ No newline at end of file
diff --git a/src/ReactorMaintenance.Win2D/MainWindow.xaml b/src/ReactorMaintenance.Win2D/MainWindow.xaml
index 84bcc3c..07ceeae 100644
--- a/src/ReactorMaintenance.Win2D/MainWindow.xaml
+++ b/src/ReactorMaintenance.Win2D/MainWindow.xaml
@@ -52,7 +52,7 @@
@@ -96,6 +96,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -111,4 +139,4 @@
-
\ No newline at end of file
+
diff --git a/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs b/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs
index 4a5d992..4c68a01 100644
--- a/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs
+++ b/src/ReactorMaintenance.Win2D/MainWindow.xaml.cs
@@ -28,9 +28,9 @@ public sealed partial class MainWindow
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 +40,7 @@ public sealed partial class MainWindow
InitializeComponent();
m_Level = BuildStarterLevel();
- m_EditorTools = Enum.GetValues().Select(tool => new EditorToolViewModel(tool, ToolLabel(tool)) { IsSelected = tool == m_SelectedTool }).ToArray();
+ m_EditorTools = BuildEditorTools();
ToolPicker.ItemsSource = m_EditorTools;
RefreshInspector();
}
@@ -69,9 +69,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 +82,7 @@ public sealed partial class MainWindow
m_Level = BuildStarterLevel();
m_CurrentFile = null;
m_SelectedCell = null;
+ ClearPendingEditorOperation();
RefreshInspector();
LevelCanvas.Invalidate();
}
@@ -100,6 +104,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();
}
@@ -247,11 +253,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 +272,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(),
@@ -478,6 +487,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 +631,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)}";
@@ -555,28 +726,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 AllPositions()
{
return AllPositions(m_Level);
@@ -667,29 +816,92 @@ public sealed partial class MainWindow
};
}
- private static string ToolLabel(EEditorTool tool)
+ private static IReadOnlyList 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())
+ {
+ 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;
@@ -714,7 +926,10 @@ public sealed partial class MainWindow
private double m_PanY;
private CanvasBitmap? m_RobotSprite;
private GridPosition? m_SelectedCell;
- private EEditorTool m_SelectedTool = EEditorTool.Cursor;
+ private int? m_SelectedReactorId = 1;
+ private GridPosition? m_PendingDoorCell;
+ private GridPosition? m_PendingElectricityLeakCell;
+ private EditorToolCommand m_SelectedTool = new() { Tool = EEditorTool.Cursor };
private CanvasBitmap? m_TerrainTilemap;
private double m_Zoom = 1;
}
\ No newline at end of file
diff --git a/tests/ReactorMaintenance.Simulation.Tests/LevelEditorTests.cs b/tests/ReactorMaintenance.Simulation.Tests/LevelEditorTests.cs
new file mode 100644
index 0000000..5de1c31
--- /dev/null
+++ b/tests/ReactorMaintenance.Simulation.Tests/LevelEditorTests.cs
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs
index 0cb6df5..f269f1a 100644
--- a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs
+++ b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs
@@ -121,6 +121,18 @@ public sealed class SimulationEngineTests
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()
{
@@ -336,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()
{