Finish rewrite task list

This commit is contained in:
2026-05-10 22:35:25 +02:00
parent 5a186fb606
commit 3a52db0071
10 changed files with 575 additions and 175 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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}";
}
}

View File

@@ -52,7 +52,7 @@
<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."
Text="Door and wall electricity leaks use two clicks: choose the source cell, then the adjacent floor face."
Foreground="#9EA7AE"
TextWrapping="Wrap" />
</StackPanel>
@@ -96,6 +96,34 @@
<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>
@@ -111,4 +139,4 @@
</ScrollViewer>
</Grid>
</Grid>
</Window>
</Window>

View File

@@ -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<EEditorTool>().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<GridPosition> AllPositions()
{
return AllPositions(m_Level);
@@ -667,29 +816,92 @@ 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;
@@ -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;
}

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

@@ -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()
{