Tighten editor validation coverage

This commit is contained in:
2026-05-14 10:29:36 +02:00
parent 6decf2a9d2
commit ec8761f4e8
5 changed files with 50 additions and 5 deletions

View File

@@ -4,7 +4,7 @@ This backlog tracks what must change so the implementation matches `docs/design.
## Audit Snapshot ## 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. - 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`. - 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. - Unrelated Godot metadata or generated `.uid` files are not part of this backlog unless a later implementation task intentionally touches them.

View File

@@ -144,8 +144,8 @@ public static class LevelEditor
EEditorTool.Junction => SetFloorProp(level, position, new() { Type = EPropType.Junction }), EEditorTool.Junction => SetFloorProp(level, position, new() { Type = EPropType.Junction }),
EEditorTool.Door => ToggleOrSetDoor(level, position), EEditorTool.Door => ToggleOrSetDoor(level, position),
EEditorTool.AllSeeingEyeTerminal => SetFloorProp(level, position, new() { Type = EPropType.AllSeeingEyeTerminal }), EEditorTool.AllSeeingEyeTerminal => SetFloorProp(level, position, new() { Type = EPropType.AllSeeingEyeTerminal }),
EEditorTool.SprinklerControl => SetFloorProp(level, position, new() { Type = EPropType.SprinklerControl, SwitchState = EPropSwitchState.Enabled }), EEditorTool.SprinklerControl => SetSprinklerControl(level, position),
EEditorTool.SprinklerValve => SetWallProp(level, position, new() { Type = EPropType.SprinklerValve, Carrier = ECarrierType.Water, OutletPosition = position.Neighbors().FirstOrDefault(level.IsFloor) }), EEditorTool.SprinklerValve => SetSprinklerValve(level, position),
EEditorTool.RemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = command.RemedyType }), EEditorTool.RemedySupply => SetFloorProp(level, position, new() { Type = EPropType.RemedySupply, RemedyType = command.RemedyType }),
EEditorTool.ReactorControl => SetReactorControl(level, position), EEditorTool.ReactorControl => SetReactorControl(level, position),
EEditorTool.Leak => SetLeak(level, position, command.Carrier), 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; 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) private static LevelState ToggleOrSetDoor(LevelState level, GridPosition position)
{ {
if (!level.IsFloor(position)) if (!level.IsFloor(position))

View File

@@ -35,8 +35,9 @@ public sealed class LevelValidator
continue; continue;
} }
if (!level.GetUnderground(position, prop.Carrier).IsPresent) var presentCarriers = Enum.GetValues<ECarrierType>().Count(carrier => level.GetUnderground(position, carrier).IsPresent);
errors.Add(new("Isolation valve must sit on its matching underground carrier.", position)); 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)); var westEastWalls = IsWall(level, new(position.X - 1, position.Y)) && IsWall(level, new(position.X + 1, position.Y));
if (northSouthWalls == westEastWalls) if (northSouthWalls == westEastWalls)
errors.Add(new("Door must be surrounded by one opposing pair of wall cells.", position)); 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));
} }
} }

View File

@@ -92,6 +92,7 @@ public sealed class LevelEditorTests
Assert.Equal(EPropType.SprinklerControl, valve.GetProp(new(1, 2)).Type); Assert.Equal(EPropType.SprinklerControl, valve.GetProp(new(1, 2)).Type);
Assert.Equal(EPropType.SprinklerValve, valve.GetProp(new(2, 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); Assert.Equal(new(2, 1), valve.GetProp(new(2, 2)).OutletPosition);
} }

View File

@@ -403,6 +403,20 @@ public sealed class SimulationEngineTests
Assert.Contains(report.Errors, error => error.Message.Contains("Wall cell", StringComparison.Ordinal)); 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] [Fact]
public void LevelSerializationRoundTripsCurrentSchemaOnly() public void LevelSerializationRoundTripsCurrentSchemaOnly()
{ {