Expand rule event coverage

This commit is contained in:
2026-05-10 17:38:43 +02:00
parent cb28eee1dd
commit a0b10423ac
5 changed files with 308 additions and 13 deletions

View File

@@ -40,6 +40,12 @@
- Added tests for T-junction ratio splits, zero-weight branches, ambiguous junction validation, and best-path flow into non-junction cells. - Added tests for T-junction ratio splits, zero-weight branches, ambiguous junction validation, and best-path flow into non-junction cells.
- Verified `dotnet test tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj` passes: 15 passed. - Verified `dotnet test tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj` passes: 15 passed.
- Ran `jb cleanupcode --build=False ...` and `python D:\Code\crlf.py ...` for touched C# files after the junction slice. - Ran `jb cleanupcode --build=False ...` and `python D:\Code\crlf.py ...` for touched C# files after the junction slice.
- Expanded rule predicates with reactor readiness/loss/win, network value bands, and robot inventory checks.
- Expanded rule effects with removal of surface hazards, heat, and inventory, plus authored access positions for leak-start effects.
- Hardened rule event validation for predicate targets, effect targets, leak access, and non-negative amount effects.
- Added tests for network-band rules, reactor-ready rules, inventory rules, removal effects, electricity leak access, and invalid rule targets.
- Verified `dotnet test tests/ReactorMaintenance.Simulation.Tests/ReactorMaintenance.Simulation.Tests.csproj` passes after the rule slice: 21 passed.
- Ran `jb cleanupcode --build=False ...` and `python D:\Code\crlf.py ...` for touched C# files after the rule slice.
## Current Work ## Current Work
@@ -47,10 +53,10 @@
## Future Work ## Future Work
1. Expand simulation fidelity where the first slice is intentionally simplified: complete pair table coverage, richer rule predicates/effects, and stronger forecast proof cases. 1. Expand simulation fidelity where the first slice is intentionally simplified: complete pair table coverage and stronger forecast proof cases.
2. Add advanced editor workflows for explicit reactor binding selection, explicit door edge selection, electricity wall leak face selection, and rule event authoring. 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. 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. 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. 5. Build the Win2D project on a Windows-capable environment after the editor rewrite.
6. Add broader tests for junction ratios, ambiguous junctions, all rule event families, serialization edge cases, and editor operations. 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. 7. Run cleanup when `dotnet-jb` is available, tests, code review, and iterate until the implementation is clean and maintainable.

View File

@@ -121,14 +121,107 @@ public sealed class LevelValidator
{ {
foreach (var ruleEvent in level.RuleEvents) foreach (var ruleEvent in level.RuleEvents)
{ {
foreach (var predicate in ruleEvent.Predicates)
ValidateRulePredicate(level, predicate, errors);
foreach (var effect in ruleEvent.Effects) foreach (var effect in ruleEvent.Effects)
{ ValidateRuleEffect(level, effect, errors);
if (!level.InBounds(effect.Position) && effect.Kind != ERuleEffectKind.EmitWarning && effect.Kind != ERuleEffectKind.MarkTerminalLoss && effect.Kind != ERuleEffectKind.AddInventory)
errors.Add(new("Rule effect target is out of bounds.", effect.Position));
}
} }
} }
private static void ValidateRulePredicate(LevelState level, RulePredicate predicate, List<ValidationIssue> errors)
{
switch (predicate.Kind)
{
case ERulePredicateKind.PropStateAt:
if (!level.InBounds(predicate.Position) || level.GetProp(predicate.Position).Type == EPropType.None)
errors.Add(new("Rule prop predicate must target a prop.", predicate.Position));
break;
case ERulePredicateKind.ConsumerStateAt:
if (!IsProp(level, predicate.Position, EPropType.Consumer))
errors.Add(new("Rule consumer predicate must target a consumer prop.", predicate.Position));
break;
case ERulePredicateKind.NetworkBandAt:
if (!level.InBounds(predicate.Position) || !level.GetUnderground(predicate.Position, predicate.Carrier).IsPresent)
errors.Add(new("Rule network predicate must target an underground cell.", predicate.Position));
break;
case ERulePredicateKind.SurfaceBandAt:
case ERulePredicateKind.RobotAt:
if (!level.IsFloor(predicate.Position))
errors.Add(new("Rule floor predicate must target a floor cell.", predicate.Position));
break;
case ERulePredicateKind.ReactorReadyIs:
case ERulePredicateKind.ReactorWonIs:
ValidateOptionalReactorId(level, predicate.ReactorId, predicate.Position, errors);
break;
}
}
private static void ValidateRuleEffect(LevelState level, RuleEffect effect, List<ValidationIssue> errors)
{
if (RequiresNonNegativeAmount(effect.Kind) && effect.Amount < 0)
errors.Add(new("Rule effect amount must be non-negative.", effect.Position));
switch (effect.Kind)
{
case ERuleEffectKind.StartLeak:
ValidateRuleLeakEffect(level, effect, errors);
break;
case ERuleEffectKind.WorsenLeak:
case ERuleEffectKind.RepairNetworkCell:
case ERuleEffectKind.DisableNetworkCell:
if (!level.InBounds(effect.Position) || !level.GetUnderground(effect.Position, effect.Carrier).IsPresent)
errors.Add(new("Rule network effect must target an underground cell.", effect.Position));
break;
case ERuleEffectKind.SetPropEnabled:
if (!level.InBounds(effect.Position) || level.GetProp(effect.Position).Type == EPropType.None)
errors.Add(new("Rule prop effect must target a prop.", effect.Position));
break;
case ERuleEffectKind.AddSurfaceHazard:
case ERuleEffectKind.RemoveSurfaceHazard:
case ERuleEffectKind.AddHeat:
case ERuleEffectKind.RemoveHeat:
if (!level.IsFloor(effect.Position))
errors.Add(new("Rule surface effect must target a floor cell.", effect.Position));
break;
}
}
private static bool RequiresNonNegativeAmount(ERuleEffectKind kind)
{
return kind is ERuleEffectKind.AddSurfaceHazard
or ERuleEffectKind.RemoveSurfaceHazard
or ERuleEffectKind.AddHeat
or ERuleEffectKind.RemoveHeat
or ERuleEffectKind.AddInventory
or ERuleEffectKind.RemoveInventory;
}
private static void ValidateRuleLeakEffect(LevelState level, RuleEffect effect, List<ValidationIssue> errors)
{
var accessPosition = effect.AccessPosition ?? effect.Position;
if (!level.InBounds(effect.Position) || !level.GetUnderground(effect.Position, effect.Carrier).IsPresent)
errors.Add(new("Rule leak effect must target an underground cell.", effect.Position));
if (!level.IsFloor(accessPosition))
{
errors.Add(new("Rule leak effect must have valid floor access.", accessPosition));
return;
}
if (effect.Carrier is ECarrierType.Fuel or ECarrierType.Coolant && effect.Position != accessPosition)
errors.Add(new("Rule fuel and coolant leak effects must use their underground coordinate as access.", accessPosition));
if (effect.Carrier == ECarrierType.Electricity && effect.Position.ManhattanDistance(accessPosition) != 1)
errors.Add(new("Rule electricity leak effect access must be an adjacent floor face.", accessPosition));
}
private static void ValidateOptionalReactorId(LevelState level, int reactorId, GridPosition position, List<ValidationIssue> errors)
{
if (reactorId > 0 && level.Reactors.All(reactor => reactor.ReactorId != reactorId))
errors.Add(new("Rule reactor predicate must reference an existing reactor.", position));
}
private static void ValidateWarnings(LevelState level, List<ValidationIssue> warnings) private static void ValidateWarnings(LevelState level, List<ValidationIssue> warnings)
{ {
foreach (var carrier in Enum.GetValues<ECarrierType>()) foreach (var carrier in Enum.GetValues<ECarrierType>())

View File

@@ -1,4 +1,4 @@
namespace ReactorMaintenance.Simulation; namespace ReactorMaintenance.Simulation;
public enum ECellTerrain public enum ECellTerrain
{ {
@@ -108,10 +108,15 @@ public enum ERulePredicateKind
{ {
TurnAtLeast, TurnAtLeast,
LevelStateIs, LevelStateIs,
ReactorReadyIs,
ReactorLostIs,
ReactorWonIs,
PropStateAt, PropStateAt,
ConsumerStateAt, ConsumerStateAt,
NetworkBandAt,
SurfaceBandAt, SurfaceBandAt,
RobotAt, RobotAt,
RobotInventoryAtLeast,
AllSeeingEyeUnlocked AllSeeingEyeUnlocked
} }
@@ -123,12 +128,21 @@ public enum ERuleEffectKind
DisableNetworkCell, DisableNetworkCell,
SetPropEnabled, SetPropEnabled,
AddSurfaceHazard, AddSurfaceHazard,
RemoveSurfaceHazard,
AddHeat, AddHeat,
RemoveHeat,
AddInventory, AddInventory,
RemoveInventory,
MarkTerminalLoss, MarkTerminalLoss,
EmitWarning EmitWarning
} }
public enum ENetworkValueKind
{
Amount,
Intensity
}
public enum EBand public enum EBand
{ {
Safe, Safe,
@@ -279,10 +293,10 @@ public sealed record RobotState
public RobotState Add(ERemedyType remedy, int amount) public RobotState Add(ERemedyType remedy, int amount)
{ {
return remedy switch { return remedy switch {
ERemedyType.FuelNeutralizer => this with { FuelNeutralizers = FuelNeutralizers + amount }, ERemedyType.FuelNeutralizer => this with { FuelNeutralizers = ClampInventory(FuelNeutralizers + amount) },
ERemedyType.CoolantNeutralizer => this with { CoolantNeutralizers = CoolantNeutralizers + amount }, ERemedyType.CoolantNeutralizer => this with { CoolantNeutralizers = ClampInventory(CoolantNeutralizers + amount) },
ERemedyType.ElectricityNeutralizer => this with { ElectricityNeutralizers = ElectricityNeutralizers + amount }, ERemedyType.ElectricityNeutralizer => this with { ElectricityNeutralizers = ClampInventory(ElectricityNeutralizers + amount) },
ERemedyType.HeatShield => this with { HeatShields = HeatShields + amount }, ERemedyType.HeatShield => this with { HeatShields = ClampInventory(HeatShields + amount) },
_ => throw new ArgumentOutOfRangeException(nameof(remedy), remedy, "Unsupported remedy.") _ => throw new ArgumentOutOfRangeException(nameof(remedy), remedy, "Unsupported remedy.")
}; };
} }
@@ -291,18 +305,27 @@ public sealed record RobotState
{ {
return Count(remedy) <= 0 ? this : Add(remedy, -1); return Count(remedy) <= 0 ? this : Add(remedy, -1);
} }
private static int ClampInventory(int value)
{
return Math.Clamp(value, 0, Balancing.Current.InventoryCapacityPerRemedy);
}
} }
public sealed record RulePredicate public sealed record RulePredicate
{ {
public ERulePredicateKind Kind { get; init; } public ERulePredicateKind Kind { get; init; }
public GridPosition Position { get; init; } = new(0, 0); public GridPosition Position { get; init; } = new(0, 0);
public int ReactorId { get; init; }
public int Turn { get; init; } public int Turn { get; init; }
public ELevelState LevelState { get; init; } public ELevelState LevelState { get; init; }
public EPropSwitchState PropSwitchState { get; init; } public EPropSwitchState PropSwitchState { get; init; }
public EConsumerServiceState ConsumerServiceState { get; init; } public EConsumerServiceState ConsumerServiceState { get; init; }
public ECarrierType Carrier { get; init; } public ECarrierType Carrier { get; init; }
public ENetworkValueKind NetworkValue { get; init; }
public ERemedyType Remedy { get; init; }
public EBand Band { get; init; } public EBand Band { get; init; }
public int InventoryCount { get; init; }
public bool BoolValue { get; init; } public bool BoolValue { get; init; }
} }
@@ -310,6 +333,7 @@ public sealed record RuleEffect
{ {
public ERuleEffectKind Kind { get; init; } public ERuleEffectKind Kind { get; init; }
public GridPosition Position { get; init; } = new(0, 0); public GridPosition Position { get; init; } = new(0, 0);
public GridPosition? AccessPosition { get; init; }
public ECarrierType Carrier { get; init; } public ECarrierType Carrier { get; init; }
public ERemedyType Remedy { get; init; } public ERemedyType Remedy { get; init; }
public float Amount { get; init; } public float Amount { get; init; }

View File

@@ -447,6 +447,24 @@ public sealed class SimulationEngine
return level.InBounds(position) && level.GetProp(position) is { Type: EPropType.Consumer, Carrier: var consumerCarrier, ServiceState: EConsumerServiceState.Producing } && consumerCarrier == carrier; return level.InBounds(position) && level.GetProp(position) is { Type: EPropType.Consumer, Carrier: var consumerCarrier, ServiceState: EConsumerServiceState.Producing } && consumerCarrier == carrier;
} }
private static bool ReactorMatches(LevelState level, RulePredicate predicate, Func<ReactorBinding, bool> selector)
{
return level.Reactors.Any(reactor => MatchesReactorId(reactor, predicate.ReactorId) && selector(reactor)) == predicate.BoolValue;
}
private static bool MatchesReactorId(ReactorBinding reactor, int reactorId)
{
return reactorId <= 0 || reactor.ReactorId == reactorId;
}
private static bool ReactorWonMatches(LevelState level, RulePredicate predicate)
{
var won = predicate.ReactorId > 0
? level.Reactors.Any(reactor => reactor.ReactorId == predicate.ReactorId && reactor.Activated)
: level.Global.LevelState == ELevelState.Won || level.Reactors.Any(reactor => reactor.Activated);
return won == predicate.BoolValue;
}
private LevelState ApplyRuleEvents(LevelState level, ERuleEventPhase phase) private LevelState ApplyRuleEvents(LevelState level, ERuleEventPhase phase)
{ {
var next = level; var next = level;
@@ -472,10 +490,15 @@ public sealed class SimulationEngine
return predicate.Kind switch { return predicate.Kind switch {
ERulePredicateKind.TurnAtLeast => level.Global.Turn >= predicate.Turn, ERulePredicateKind.TurnAtLeast => level.Global.Turn >= predicate.Turn,
ERulePredicateKind.LevelStateIs => level.Global.LevelState == predicate.LevelState, ERulePredicateKind.LevelStateIs => level.Global.LevelState == predicate.LevelState,
ERulePredicateKind.ReactorReadyIs => ReactorMatches(level, predicate, reactor => reactor.Ready),
ERulePredicateKind.ReactorLostIs => (level.Global.LevelState == ELevelState.Lost) == predicate.BoolValue,
ERulePredicateKind.ReactorWonIs => ReactorWonMatches(level, predicate),
ERulePredicateKind.PropStateAt => level.InBounds(predicate.Position) && level.GetProp(predicate.Position).SwitchState == predicate.PropSwitchState, ERulePredicateKind.PropStateAt => level.InBounds(predicate.Position) && level.GetProp(predicate.Position).SwitchState == predicate.PropSwitchState,
ERulePredicateKind.ConsumerStateAt => level.InBounds(predicate.Position) && level.GetProp(predicate.Position).ServiceState == predicate.ConsumerServiceState, ERulePredicateKind.ConsumerStateAt => level.InBounds(predicate.Position) && level.GetProp(predicate.Position).ServiceState == predicate.ConsumerServiceState,
ERulePredicateKind.NetworkBandAt => level.InBounds(predicate.Position) && NetworkBand(level.GetUnderground(predicate.Position, predicate.Carrier), predicate.Carrier, predicate.NetworkValue) >= predicate.Band,
ERulePredicateKind.SurfaceBandAt => level.InBounds(predicate.Position) && SurfaceBand(level.GetSurface(predicate.Position), predicate.Carrier) >= predicate.Band, ERulePredicateKind.SurfaceBandAt => level.InBounds(predicate.Position) && SurfaceBand(level.GetSurface(predicate.Position), predicate.Carrier) >= predicate.Band,
ERulePredicateKind.RobotAt => level.Robot.Position == predicate.Position, ERulePredicateKind.RobotAt => level.Robot.Position == predicate.Position,
ERulePredicateKind.RobotInventoryAtLeast => level.Robot.Count(predicate.Remedy) >= predicate.InventoryCount,
ERulePredicateKind.AllSeeingEyeUnlocked => level.Global.AllSeeingEyeUnlocked == predicate.BoolValue, ERulePredicateKind.AllSeeingEyeUnlocked => level.Global.AllSeeingEyeUnlocked == predicate.BoolValue,
_ => false _ => false
}; };
@@ -490,8 +513,11 @@ public sealed class SimulationEngine
ERuleEffectKind.DisableNetworkCell => level.SetUnderground(effect.Position, effect.Carrier, new()), ERuleEffectKind.DisableNetworkCell => level.SetUnderground(effect.Position, effect.Carrier, new()),
ERuleEffectKind.SetPropEnabled => level.SetProp(effect.Position, level.GetProp(effect.Position) with { SwitchState = effect.PropSwitchState }), ERuleEffectKind.SetPropEnabled => level.SetProp(effect.Position, level.GetProp(effect.Position) with { SwitchState = effect.PropSwitchState }),
ERuleEffectKind.AddSurfaceHazard => level.SetSurface(effect.Position, AddSurfaceCarrier(level.GetSurface(effect.Position), effect.Carrier, effect.Amount)), ERuleEffectKind.AddSurfaceHazard => level.SetSurface(effect.Position, AddSurfaceCarrier(level.GetSurface(effect.Position), effect.Carrier, effect.Amount)),
ERuleEffectKind.RemoveSurfaceHazard => level.SetSurface(effect.Position, AddSurfaceCarrier(level.GetSurface(effect.Position), effect.Carrier, -effect.Amount)),
ERuleEffectKind.AddHeat => level.SetSurface(effect.Position, level.GetSurface(effect.Position) with { Heat = level.GetSurface(effect.Position).Heat + effect.Amount }), ERuleEffectKind.AddHeat => level.SetSurface(effect.Position, level.GetSurface(effect.Position) with { Heat = level.GetSurface(effect.Position).Heat + effect.Amount }),
ERuleEffectKind.RemoveHeat => level.SetSurface(effect.Position, level.GetSurface(effect.Position) with { Heat = level.GetSurface(effect.Position).Heat - effect.Amount }),
ERuleEffectKind.AddInventory => level with { Robot = level.Robot.Add(effect.Remedy, (int)effect.Amount) }, ERuleEffectKind.AddInventory => level with { Robot = level.Robot.Add(effect.Remedy, (int)effect.Amount) },
ERuleEffectKind.RemoveInventory => level with { Robot = level.Robot.Add(effect.Remedy, -(int)effect.Amount) },
ERuleEffectKind.MarkTerminalLoss => level with { Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = string.IsNullOrWhiteSpace(effect.Message) ? "TERMINAL FAILURE" : effect.Message } }, ERuleEffectKind.MarkTerminalLoss => level with { Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = string.IsNullOrWhiteSpace(effect.Message) ? "TERMINAL FAILURE" : effect.Message } },
ERuleEffectKind.EmitWarning => level with { Global = level.Global with { Warnings = [.. level.Global.Warnings, effect.Message] } }, ERuleEffectKind.EmitWarning => level with { Global = level.Global with { Warnings = [.. level.Global.Warnings, effect.Message] } },
_ => level _ => level
@@ -503,7 +529,7 @@ public sealed class SimulationEngine
var leak = new LeakState { var leak = new LeakState {
Carrier = effect.Carrier, Carrier = effect.Carrier,
UndergroundPosition = effect.Position, UndergroundPosition = effect.Position,
AccessPosition = effect.Position AccessPosition = effect.AccessPosition ?? effect.Position
}; };
return level.SetUnderground(effect.Position, effect.Carrier, level.GetUnderground(effect.Position, effect.Carrier) with { State = EUndergroundState.Leaking }) with { Leaks = [.. level.Leaks, leak] }; return level.SetUnderground(effect.Position, effect.Carrier, level.GetUnderground(effect.Position, effect.Carrier) with { State = EUndergroundState.Leaking }) with { Leaks = [.. level.Leaks, leak] };
} }
@@ -625,6 +651,17 @@ public sealed class SimulationEngine
}; };
} }
private static EBand NetworkBand(UndergroundCell underground, ECarrierType carrier, ENetworkValueKind valueKind)
{
var value = valueKind == ENetworkValueKind.Amount ? underground.Amount : underground.Intensity;
return carrier switch {
ECarrierType.Fuel => BandFuel(value),
ECarrierType.Coolant => BandCoolant(value),
ECarrierType.Electricity => BandElectricity(value),
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
}
private static EBand BandFuel(float value) private static EBand BandFuel(float value)
{ {
return Balancing.Current.Band(value, Balancing.Current.FuelCaution, Balancing.Current.FuelCritical); return Balancing.Current.Band(value, Balancing.Current.FuelCaution, Balancing.Current.FuelCritical);

View File

@@ -170,6 +170,141 @@ public sealed class SimulationEngineTests
Assert.Contains(forecasts, forecast => forecast.Kind == EForecastKind.TerminalLoss); 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] [Fact]
public void ValidatorRejectsWallHazardsAndInvalidReactorBinding() public void ValidatorRejectsWallHazardsAndInvalidReactorBinding()
{ {