From ec8761f4e8c19229d1485cfcb8a695141d1a760f Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 14 May 2026 10:29:36 +0200 Subject: [PATCH] Tighten editor validation coverage --- TASKS.md | 2 +- .../LevelEditor.cs | 30 +++++++++++++++++-- .../LevelValidator.cs | 8 +++-- .../LevelEditorTests.cs | 1 + .../SimulationEngineTests.cs | 14 +++++++++ 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/TASKS.md b/TASKS.md index e43981f..c6436ba 100644 --- a/TASKS.md +++ b/TASKS.md @@ -4,7 +4,7 @@ This backlog tracks what must change so the implementation matches `docs/design. ## Audit Snapshot -- Current simulation tests pass: `54/54` in `tests/ReactorMaintenance.Simulation.Tests`. +- Current simulation tests pass: `55/55` in `tests/ReactorMaintenance.Simulation.Tests`. - Godot has a usable UX scaffold and grid renderer; the full pulse playback, terminal-gated layer controls, and campaign content pass remain in later tasks. - Existing campaign data is the older placeholder set. These levels and manifest entries must be replaced by the tutorial plus six-group campaign from `docs/CAMPAIGN.md`. - Unrelated Godot metadata or generated `.uid` files are not part of this backlog unless a later implementation task intentionally touches them. diff --git a/src/ReactorMaintenance.Simulation/LevelEditor.cs b/src/ReactorMaintenance.Simulation/LevelEditor.cs index 71dbcf5..0f5dd9c 100644 --- a/src/ReactorMaintenance.Simulation/LevelEditor.cs +++ b/src/ReactorMaintenance.Simulation/LevelEditor.cs @@ -144,8 +144,8 @@ public static class LevelEditor EEditorTool.Junction => SetFloorProp(level, position, new() { Type = EPropType.Junction }), EEditorTool.Door => ToggleOrSetDoor(level, position), EEditorTool.AllSeeingEyeTerminal => SetFloorProp(level, position, new() { Type = EPropType.AllSeeingEyeTerminal }), - EEditorTool.SprinklerControl => SetFloorProp(level, position, new() { Type = EPropType.SprinklerControl, SwitchState = EPropSwitchState.Enabled }), - EEditorTool.SprinklerValve => SetWallProp(level, position, new() { Type = EPropType.SprinklerValve, Carrier = ECarrierType.Water, OutletPosition = position.Neighbors().FirstOrDefault(level.IsFloor) }), + EEditorTool.SprinklerControl => SetSprinklerControl(level, position), + EEditorTool.SprinklerValve => SetSprinklerValve(level, position), EEditorTool.RemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = command.RemedyType }), EEditorTool.ReactorControl => SetReactorControl(level, position), EEditorTool.Leak => SetLeak(level, position, command.Carrier), @@ -217,6 +217,32 @@ public static class LevelEditor return level.InBounds(position) && level.GetTerrain(position) == ECellTerrain.Wall ? level.SetProp(position, prop) : level; } + private static LevelState SetSprinklerControl(LevelState level, GridPosition position) + { + var linkedValve = LevelTraversal.AllPositions(level).FirstOrDefault(candidate => level.GetProp(candidate).Type == EPropType.SprinklerValve); + return SetFloorProp(level, position, new() { + Type = EPropType.SprinklerControl, + SwitchState = EPropSwitchState.Enabled, + LinkedPosition = level.InBounds(linkedValve) && level.GetProp(linkedValve).Type == EPropType.SprinklerValve ? linkedValve : null + }); + } + + private static LevelState SetSprinklerValve(LevelState level, GridPosition position) + { + var next = SetWallProp(level, position, new() { + Type = EPropType.SprinklerValve, + Carrier = ECarrierType.Water, + OutletPosition = position.Neighbors().FirstOrDefault(level.IsFloor) + }); + if (next == level) + return level; + + var unlinkedControl = LevelTraversal.AllPositions(next).FirstOrDefault(candidate => next.GetProp(candidate) is { Type: EPropType.SprinklerControl, LinkedPosition: null }); + return next.InBounds(unlinkedControl) && next.GetProp(unlinkedControl).Type == EPropType.SprinklerControl + ? next.SetProp(unlinkedControl, next.GetProp(unlinkedControl) with { LinkedPosition = position }) + : next; + } + private static LevelState ToggleOrSetDoor(LevelState level, GridPosition position) { if (!level.IsFloor(position)) diff --git a/src/ReactorMaintenance.Simulation/LevelValidator.cs b/src/ReactorMaintenance.Simulation/LevelValidator.cs index 1c5d4af..50f0805 100644 --- a/src/ReactorMaintenance.Simulation/LevelValidator.cs +++ b/src/ReactorMaintenance.Simulation/LevelValidator.cs @@ -35,8 +35,9 @@ public sealed class LevelValidator continue; } - if (!level.GetUnderground(position, prop.Carrier).IsPresent) - errors.Add(new("Isolation valve must sit on its matching underground carrier.", position)); + var presentCarriers = Enum.GetValues().Count(carrier => level.GetUnderground(position, carrier).IsPresent); + if (presentCarriers != 1 || !level.GetUnderground(position, prop.Carrier).IsPresent) + errors.Add(new("Isolation valve must sit on exactly one matching underground carrier.", position)); } } @@ -95,6 +96,9 @@ public sealed class LevelValidator var westEastWalls = IsWall(level, new(position.X - 1, position.Y)) && IsWall(level, new(position.X + 1, position.Y)); if (northSouthWalls == westEastWalls) errors.Add(new("Door must be surrounded by one opposing pair of wall cells.", position)); + + if (!level.GetUnderground(position, ECarrierType.Electricity).IsPresent) + errors.Add(new("Door must have a local electricity connection.", position)); } } diff --git a/tests/ReactorMaintenance.Simulation.Tests/LevelEditorTests.cs b/tests/ReactorMaintenance.Simulation.Tests/LevelEditorTests.cs index 875760e..6dc77b2 100644 --- a/tests/ReactorMaintenance.Simulation.Tests/LevelEditorTests.cs +++ b/tests/ReactorMaintenance.Simulation.Tests/LevelEditorTests.cs @@ -92,6 +92,7 @@ public sealed class LevelEditorTests Assert.Equal(EPropType.SprinklerControl, valve.GetProp(new(1, 2)).Type); Assert.Equal(EPropType.SprinklerValve, valve.GetProp(new(2, 2)).Type); + Assert.Equal(new(2, 2), valve.GetProp(new(1, 2)).LinkedPosition); Assert.Equal(new(2, 1), valve.GetProp(new(2, 2)).OutletPosition); } diff --git a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs index 095660d..00b5ca2 100644 --- a/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs +++ b/tests/ReactorMaintenance.Simulation.Tests/SimulationEngineTests.cs @@ -403,6 +403,20 @@ public sealed class SimulationEngineTests Assert.Contains(report.Errors, error => error.Message.Contains("Wall cell", StringComparison.Ordinal)); } + [Fact] + public void ValidatorRejectsIsolationValveOnMultipleCarriers() + { + var level = LevelState.Create("Invalid valve", 5, 5); + level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, new() { State = EUndergroundState.Intact }); + level = level.SetUnderground(new(2, 2), ECarrierType.Water, new() { State = EUndergroundState.Intact }); + level = level.SetProp(new(2, 2), new() { Type = EPropType.IsolationValve, Carrier = ECarrierType.Fuel }); + + var report = new LevelValidator().Validate(level); + + Assert.False(report.IsValid); + Assert.Contains(report.Errors, error => error.Message.Contains("exactly one matching underground carrier", StringComparison.Ordinal)); + } + [Fact] public void LevelSerializationRoundTripsCurrentSchemaOnly() {