namespace ReactorMaintenance.Simulation.Tests; public sealed class SimulationEngineTests { [Fact] public void NetworkPropagationSuppliesBoundConsumersAndReadiesReactor() { var level = BuildReadyLevel(); var next = m_Engine.AdvanceTurn(level); Assert.Equal(EConsumerServiceState.Producing, next.GetProp(new(3, 2)).ServiceState); Assert.Equal(EConsumerServiceState.Producing, next.GetProp(new(3, 3)).ServiceState); Assert.Equal(EConsumerServiceState.Producing, next.GetProp(new(3, 4)).ServiceState); Assert.Equal(ELevelState.Ready, next.Global.LevelState); Assert.Contains(next.Forecasts, forecast => forecast.Kind == EForecastKind.ReactorReady); } [Fact] public void ReactorActivatesOnlyAtReadyControl() { var level = m_Engine.AdvanceTurn(BuildReadyLevel()) with { Robot = new() { Position = new(5, 3) } }; var activated = m_Engine.ActivateReactor(level); Assert.Equal(ELevelState.Won, activated.Global.LevelState); Assert.True(activated.Reactors[0].Activated); } [Fact] public void LeakingUndergroundCellInjectsMatchingSurfaceHazard() { var level = LevelState.Create("Leak", 6, 6); level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, new() { State = EUndergroundState.Leaking, Amount = 5, Intensity = 5 }) with { Leaks = [new LeakState { Carrier = ECarrierType.Fuel, UndergroundPosition = new(2, 2), AccessPosition = new(2, 2) }] }; level = level.SetProp(new(2, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel }); var next = m_Engine.AdvanceTurn(level); Assert.True(next.GetSurface(new(2, 2)).Fuel > 0); } [Fact] public void ElementRemedyClearsHazardAndBlocksImmediateReentry() { var level = LevelState.Create("Remedy", 6, 6); level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, new() { State = EUndergroundState.Leaking, Amount = 5, Intensity = 5 }); level = level.SetSurface(new(2, 2), new() { Fuel = 5 }) with { Robot = new() { Position = new(2, 2), FuelNeutralizers = 1 }, Leaks = [new LeakState { Carrier = ECarrierType.Fuel, UndergroundPosition = new(2, 2), AccessPosition = new(2, 2) }] }; var next = m_Engine.InteractLeak(level, ECarrierType.Fuel, true); Assert.Equal(0, next.GetSurface(new(2, 2)).Fuel); Assert.True(next.GetSurface(new(2, 2)).FuelBlockTurns > 0); Assert.Equal(0, next.Robot.FuelNeutralizers); } [Fact] public void ClosedDoorBlocksAdjacentHeatFlow() { var level = LevelState.Create("Door", 6, 6); level = level.SetSurface(new(2, 2), new() { Heat = 8 }) with { Doors = [new DoorState { A = new(2, 2), B = new(3, 2), State = EDoorState.Closed }] }; var next = m_Engine.AdvanceTurn(level); Assert.Equal(0, next.GetSurface(new(3, 2)).Heat); } [Fact] public void HeatShieldPreventsRobotHeatLoss() { var level = LevelState.Create("Heat shield", 6, 6); level = level.SetSurface(new(2, 2), new() { Heat = Balancing.Current.RobotHeatSafetyThreshold }) with { Robot = new() { Position = new(2, 2), HeatImmunitySteps = 1 } }; var next = m_Engine.AdvanceTurn(level); 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() { var level = LevelState.Create("Unsafe", 6, 6); level = level.SetSurface(new(2, 2), new() { Electricity = Balancing.Current.MaxValue }) with { Robot = new() { Position = new(2, 2) } }; var next = m_Engine.AdvanceTurn(level); Assert.Equal(ELevelState.Lost, next.Global.LevelState); } [Fact] public void RuleEventCanCreateTerminalLossForecast() { var level = LevelState.Create("Rule", 6, 6) with { RuleEvents = [ new RuleEventState { Phase = ERuleEventPhase.EndOfTurn, ForecastText = "containment failure", Predicates = [new RulePredicate { Kind = ERulePredicateKind.TurnAtLeast, Turn = 0 }], Effects = [new RuleEffect { Kind = ERuleEffectKind.MarkTerminalLoss, Message = "CONTAINMENT FAILURE" }] } ] }; var forecasts = m_Engine.Forecast(level); Assert.Contains(forecasts, forecast => forecast.Kind == EForecastKind.RuleEvent && forecast.Message == "containment failure"); Assert.Contains(forecasts, forecast => forecast.Kind == EForecastKind.TerminalLoss); } [Fact] public void RuleEventCanTriggerFromNetworkBand() { var level = LevelState.Create("Network rule", 6, 6); level = AddLine(level, ECarrierType.Fuel, new(2, 2), new(3, 2)); level = level.SetProp(new(2, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel }) with { RuleEvents = [ new RuleEventState { Phase = ERuleEventPhase.EndOfTurn, Predicates = [new RulePredicate { Kind = ERulePredicateKind.NetworkBandAt, Position = new(3, 2), Carrier = ECarrierType.Fuel, NetworkValue = ENetworkValueKind.Amount, Band = EBand.Critical }], Effects = [new RuleEffect { Kind = ERuleEffectKind.EmitWarning, Message = "fuel pressure high" }] } ] }; var next = m_Engine.AdvanceTurn(level); Assert.Contains("fuel pressure high", next.Global.Warnings); } [Fact] public void RuleEventCanTriggerFromReactorReadiness() { var level = BuildReadyLevel() with { RuleEvents = [ new RuleEventState { Phase = ERuleEventPhase.EndOfTurn, Predicates = [new RulePredicate { Kind = ERulePredicateKind.ReactorReadyIs, ReactorId = 1, BoolValue = true }], Effects = [new RuleEffect { Kind = ERuleEffectKind.EmitWarning, Message = "reactor ready rule" }] } ] }; var next = m_Engine.AdvanceTurn(level); Assert.Contains("reactor ready rule", next.Global.Warnings); } [Fact] public void RuleEventCanTriggerFromRobotInventory() { var level = LevelState.Create("Inventory rule", 6, 6) with { Robot = new() { Position = new(1, 1), FuelNeutralizers = 1 }, RuleEvents = [ new RuleEventState { Phase = ERuleEventPhase.StartOfSimulation, Predicates = [new RulePredicate { Kind = ERulePredicateKind.RobotInventoryAtLeast, Remedy = ERemedyType.FuelNeutralizer, InventoryCount = 1 }], Effects = [new RuleEffect { Kind = ERuleEffectKind.EmitWarning, Message = "fuel kit detected" }] } ] }; var next = m_Engine.AdvanceTurn(level); Assert.Contains("fuel kit detected", next.Global.Warnings); } [Fact] public void RuleEventCanRemoveHazardsHeatAndInventory() { var level = LevelState.Create("Remove rule", 6, 6); level = level.SetSurface(new(2, 2), new() { Fuel = 5, Heat = 5 }) with { Robot = new() { Position = new(1, 1), FuelNeutralizers = 2 }, Doors = [ new DoorState { A = new(2, 2), B = new(1, 2), State = EDoorState.Closed }, new DoorState { A = new(2, 2), B = new(3, 2), State = EDoorState.Closed }, new DoorState { A = new(2, 2), B = new(2, 1), State = EDoorState.Closed }, new DoorState { A = new(2, 2), B = new(2, 3), State = EDoorState.Closed } ], RuleEvents = [ new RuleEventState { Phase = ERuleEventPhase.StartOfSimulation, Predicates = [new RulePredicate { Kind = ERulePredicateKind.TurnAtLeast, Turn = 0 }], Effects = [ new RuleEffect { Kind = ERuleEffectKind.RemoveSurfaceHazard, Position = new(2, 2), Carrier = ECarrierType.Fuel, Amount = 2 }, new RuleEffect { Kind = ERuleEffectKind.RemoveHeat, Position = new(2, 2), Amount = 3 }, new RuleEffect { Kind = ERuleEffectKind.RemoveInventory, Remedy = ERemedyType.FuelNeutralizer, Amount = 1 } ] } ] }; var next = m_Engine.AdvanceTurn(level); Assert.Equal(3, next.GetSurface(new(2, 2)).Fuel); Assert.Equal(2, next.GetSurface(new(2, 2)).Heat); Assert.Equal(1, next.Robot.FuelNeutralizers); } [Fact] public void RuleEventStartLeakUsesAuthoredElectricityAccessFace() { var level = LevelState.Create("Electricity leak rule", 6, 6); level = level.SetTerrain(new(2, 2), ECellTerrain.Wall); level = level.SetUnderground(new(2, 2), ECarrierType.Electricity, new() { State = EUndergroundState.Intact }) with { RuleEvents = [ new RuleEventState { Phase = ERuleEventPhase.StartOfSimulation, Predicates = [new RulePredicate { Kind = ERulePredicateKind.TurnAtLeast, Turn = 0 }], Effects = [new RuleEffect { Kind = ERuleEffectKind.StartLeak, Position = new(2, 2), AccessPosition = new(2, 3), Carrier = ECarrierType.Electricity }] } ] }; var next = m_Engine.AdvanceTurn(level); Assert.Single(next.Leaks); Assert.Equal(new(2, 3), next.Leaks[0].AccessPosition); Assert.True(next.GetSurface(new(2, 3)).Electricity > 0); } [Fact] public void ValidatorRejectsInvalidRuleTargets() { var level = LevelState.Create("Invalid rules", 6, 6); level = level.SetTerrain(new(2, 2), ECellTerrain.Wall) with { RuleEvents = [ new RuleEventState { Predicates = [new RulePredicate { Kind = ERulePredicateKind.PropStateAt, Position = new(1, 1) }], Effects = [ new RuleEffect { Kind = ERuleEffectKind.AddSurfaceHazard, Position = new(2, 2), Carrier = ECarrierType.Fuel, Amount = 1 }, new RuleEffect { Kind = ERuleEffectKind.RepairNetworkCell, Position = new(3, 3), Carrier = ECarrierType.Coolant } ] } ] }; var report = new LevelValidator().Validate(level); Assert.False(report.IsValid); Assert.Contains(report.Errors, error => error.Message.Contains("Rule prop predicate", StringComparison.Ordinal)); Assert.Contains(report.Errors, error => error.Message.Contains("Rule surface effect", StringComparison.Ordinal)); Assert.Contains(report.Errors, error => error.Message.Contains("Rule network effect", StringComparison.Ordinal)); } [Fact] public void ValidatorRejectsWallHazardsAndInvalidReactorBinding() { var level = LevelState.Create("Invalid", 6, 6); level = level.SetTerrain(new(2, 2), ECellTerrain.Wall); level = level with { Surface = level.Surface.ToArray(), Reactors = [new ReactorBinding { ControlPosition = new(3, 3), FuelConsumerPosition = new(1, 1), CoolantConsumerPosition = new(1, 1), ElectricityConsumerPosition = new(1, 1) }] }; level.Surface[level.Index(new(2, 2))] = new() { Heat = 1 }; var report = new LevelValidator().Validate(level); Assert.False(report.IsValid); Assert.Contains(report.Errors, error => error.Message.Contains("Wall cell", StringComparison.Ordinal)); Assert.Contains(report.Errors, error => error.Message.Contains("Reactor binding", StringComparison.Ordinal)); } [Fact] public void LevelSerializationRoundTripsCurrentSchemaOnly() { var level = BuildReadyLevel(); var json = LevelSerializer.Serialize(level); var loaded = LevelSerializer.Deserialize(json); Assert.Contains("\"Version\": 2", json); Assert.Equal(level.Name, loaded.Name); Assert.Equal(EPropType.Flow, loaded.GetProp(new(2, 2)).Type); } [Fact] public void LevelSerializationRejectsOldSchema() { var json = """ { "Version": 1, "Level": {} } """; var exception = Assert.Throws(() => LevelSerializer.Deserialize(json)); Assert.Contains("Unsupported level file version 1", exception.Message); } private static LevelState BuildReadyLevel() { var level = LevelState.Create("Ready", 8, 7); level = AddLine(level, ECarrierType.Fuel, new(2, 2), new(3, 2)); level = AddLine(level, ECarrierType.Coolant, new(2, 3), new(3, 3)); level = AddLine(level, ECarrierType.Electricity, new(2, 4), new(3, 4)); level = level.SetProp(new(2, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel }); level = level.SetProp(new(2, 3), new() { Type = EPropType.Flow, Carrier = ECarrierType.Coolant }); level = level.SetProp(new(2, 4), new() { Type = EPropType.Flow, Carrier = ECarrierType.Electricity }); level = level.SetProp(new(3, 2), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Fuel }); level = level.SetProp(new(3, 3), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Coolant }); level = level.SetProp(new(3, 4), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Electricity }); level = level.SetProp(new(5, 3), new() { Type = EPropType.ReactorControl, ReactorId = 1 }); return level with { Robot = new() { Position = new(5, 3) }, Reactors = [ new ReactorBinding { ReactorId = 1, ControlPosition = new(5, 3), FuelConsumerPosition = new(3, 2), CoolantConsumerPosition = new(3, 3), ElectricityConsumerPosition = new(3, 4) } ] }; } private static LevelState AddLine(LevelState level, ECarrierType carrier, GridPosition a, GridPosition b) { level = level.SetUnderground(a, carrier, new() { State = EUndergroundState.Intact }); level = level.SetUnderground(b, carrier, new() { State = EUndergroundState.Intact }); 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(); }