Rework simulation rules

This commit is contained in:
2026-05-11 22:18:43 +02:00
parent 3d406179bf
commit e1ac56d201
30 changed files with 554 additions and 848 deletions

View File

@@ -60,6 +60,16 @@ public abstract class Balancing
return new() { Verb = ESurfaceInteractionVerb.Flow, Quantity = quantity, Amount = FlowTransferRatio };
}
public float StructuralPressureThreshold(ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => FuelCritical,
ECarrierType.Coolant => CoolantCritical,
ECarrierType.Electricity => ElectricityCritical,
_ => MaxValue
};
}
private SurfaceInteractionEffect Warm(EBand rowBand, EBand colBand)
{
return new() {
@@ -110,7 +120,6 @@ public abstract class Balancing
public abstract int DefaultLevelWidth { get; }
public abstract int DefaultLevelHeight { get; }
public abstract int MinimumLevelSize { get; }
public abstract int ActionsPerTurn { get; }
public abstract int ForecastHorizon { get; }
public abstract float MinValue { get; }
public abstract float MaxValue { get; }
@@ -139,6 +148,9 @@ public abstract class Balancing
public abstract IReadOnlyList<JunctionRatioPreset> ThreeOutflowJunctionRatios { get; }
public abstract float ConsumerRequiredAmount { get; }
public abstract float ConsumerRequiredIntensity { get; }
public abstract int MaxStructuralIntegrity { get; }
public abstract int StructuralIntegrityLeakThreshold { get; }
public abstract float StructuralIntegrityDamageScale { get; }
public abstract float LeakBaseAmount { get; }
public abstract float LeakAmountScale { get; }
public abstract float LeakIntensityScale { get; }

View File

@@ -5,7 +5,6 @@ public class NormalBalancing : Balancing
public override int DefaultLevelWidth => 16;
public override int DefaultLevelHeight => 12;
public override int MinimumLevelSize => 4;
public override int ActionsPerTurn => 3;
public override int ForecastHorizon => 6;
public override float MinValue => 0;
public override float MaxValue => 10;
@@ -48,6 +47,9 @@ public class NormalBalancing : Balancing
public override float ConsumerRequiredAmount => 2.5f;
public override float ConsumerRequiredIntensity => 2.5f;
public override int MaxStructuralIntegrity => 10;
public override int StructuralIntegrityLeakThreshold => 2;
public override float StructuralIntegrityDamageScale => 0.35f;
public override float LeakBaseAmount => 0.5f;
public override float LeakAmountScale => 0.15f;
public override float LeakIntensityScale => 0.1f;

View File

@@ -13,7 +13,7 @@ public static class LevelEditor
EEditorTool.Wall => level.SetTerrain(position, ECellTerrain.Wall),
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.Consumer => SetFloorProp(level, position, new() { Type = EPropType.Consumer, SwitchState = EPropSwitchState.Enabled }),
EEditorTool.Junction => SetFloorProp(level, position, new() { Type = EPropType.Junction }),
EEditorTool.Door => SetFloorProp(level, position, new() { Type = EPropType.Door }),
EEditorTool.AllSeeingEyeTerminal => SetFloorProp(level, position, new() { Type = EPropType.AllSeeingEyeTerminal }),
@@ -27,19 +27,6 @@ public static class LevelEditor
};
}
public static LevelState SetDoorEdge(LevelState level, GridPosition a, GridPosition b)
{
if (!level.IsFloor(a) || !level.IsFloor(b) || a.ManhattanDistance(b) != 1)
return level;
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))
@@ -51,7 +38,10 @@ public static class LevelEditor
if (carrier == ECarrierType.Electricity && undergroundPosition.ManhattanDistance(accessPosition) != 1)
return level;
var next = level.SetUnderground(undergroundPosition, carrier, new() { State = EUndergroundState.Leaking });
var next = level.SetUnderground(undergroundPosition, carrier, new() {
State = EUndergroundState.Leaking,
StructuralIntegrity = Balancing.Current.StructuralIntegrityLeakThreshold
});
return next with {
Leaks = [
.. next.Leaks.Where(leak => leak.UndergroundPosition != undergroundPosition || leak.Carrier != carrier),
@@ -64,32 +54,12 @@ public static class LevelEditor
};
}
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 });
return level.SetUnderground(position, carrier, new() {
State = EUndergroundState.Intact,
StructuralIntegrity = Balancing.Current.MaxStructuralIntegrity
});
}
private static LevelState SetCarrierProp(LevelState level, GridPosition position, EPropType type, ECarrierType carrier)
@@ -125,10 +95,7 @@ public static class LevelEditor
.. level.Reactors,
new() {
ReactorId = id,
ControlPosition = position,
FuelConsumerPosition = position,
CoolantConsumerPosition = position,
ElectricityConsumerPosition = position
ControlPosition = position
}
]
};
@@ -141,28 +108,4 @@ public static class LevelEditor
return SetLeak(level, position, position, carrier);
}
private static bool SameDoorEdge(DoorState door, GridPosition a, GridPosition b)
{
return (door.A == a && door.B == b) || (door.A == b && door.B == a);
}
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

@@ -1,4 +1,4 @@
using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ReactorMaintenance.Simulation;
@@ -33,7 +33,7 @@ public static class LevelSerializer
return level;
}
private const int c_CurrentVersion = 2;
private const int c_CurrentVersion = 3;
private static readonly JsonSerializerOptions s_Options = new() {
WriteIndented = true,

View File

@@ -42,7 +42,7 @@ public static class LevelStateExtensions
public static bool IsClosedDoorEdge(this LevelState level, GridPosition a, GridPosition b)
{
return level.Doors.Any(door => door.State == EDoorState.Closed && SameEdge(door.A, door.B, a, b));
return DoorBlocksEdge(level, a, b) || DoorBlocksEdge(level, b, a);
}
public static LevelState SetTerrain(this LevelState level, GridPosition position, ECellTerrain terrain)
@@ -109,8 +109,31 @@ public static class LevelStateExtensions
.SetUnderground(position, ECarrierType.Electricity, new());
}
private static bool SameEdge(GridPosition edgeA, GridPosition edgeB, GridPosition a, GridPosition b)
private static bool DoorBlocksEdge(LevelState level, GridPosition doorPosition, GridPosition neighbor)
{
return (edgeA == a && edgeB == b) || (edgeA == b && edgeB == a);
if (!level.InBounds(doorPosition) || !level.InBounds(neighbor))
return false;
var prop = level.GetProp(doorPosition);
if (prop is not { Type: EPropType.Door, DoorState: EDoorState.Closed } || doorPosition.ManhattanDistance(neighbor) != 1)
return false;
var north = new GridPosition(doorPosition.X, doorPosition.Y - 1);
var south = new GridPosition(doorPosition.X, doorPosition.Y + 1);
var west = new GridPosition(doorPosition.X - 1, doorPosition.Y);
var east = new GridPosition(doorPosition.X + 1, doorPosition.Y);
if (IsWall(level, north) && IsWall(level, south))
return neighbor.Y == doorPosition.Y;
if (IsWall(level, west) && IsWall(level, east))
return neighbor.X == doorPosition.X;
return false;
}
private static bool IsWall(LevelState level, GridPosition position)
{
return level.InBounds(position) && level.GetTerrain(position) == ECellTerrain.Wall;
}
}

View File

@@ -14,7 +14,6 @@ public sealed class LevelValidator
ValidateLeaks(level, errors);
ValidateReactors(level, errors, warnings);
ValidateJunctions(level, errors);
ValidateRuleEvents(level, errors);
ValidateWarnings(level, warnings);
return new() { Errors = errors, Warnings = warnings };
@@ -60,10 +59,21 @@ public sealed class LevelValidator
private static void ValidateDoors(LevelState level, List<ValidationIssue> errors)
{
foreach (var door in level.Doors)
foreach (var position in LevelTraversal.AllPositions(level))
{
if (!level.IsFloor(door.A) || !level.IsFloor(door.B) || door.A.ManhattanDistance(door.B) != 1)
errors.Add(new("Door edge must connect two adjacent floor cells.", door.A));
if (level.GetProp(position).Type != EPropType.Door)
continue;
if (!level.IsFloor(position))
{
errors.Add(new("Door prop must be placed on a floor cell.", position));
continue;
}
var northSouthWalls = IsWall(level, new(position.X, position.Y - 1)) && IsWall(level, new(position.X, position.Y + 1));
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));
}
}
@@ -94,21 +104,14 @@ public sealed class LevelValidator
foreach (var reactor in level.Reactors)
{
if (!IsProp(level, reactor.ControlPosition, EPropType.ReactorControl))
errors.Add(new("Reactor binding control position must point to a reactor control prop.", reactor.ControlPosition));
ValidateConsumerBinding(level, reactor.FuelConsumerPosition, ECarrierType.Fuel, errors);
ValidateConsumerBinding(level, reactor.CoolantConsumerPosition, ECarrierType.Coolant, errors);
ValidateConsumerBinding(level, reactor.ElectricityConsumerPosition, ECarrierType.Electricity, errors);
errors.Add(new("Reactor control position must point to a reactor control prop.", reactor.ControlPosition));
if (!reactor.Ready)
warnings.Add(new("Reactor is initially unready.", reactor.ControlPosition));
}
}
private static void ValidateConsumerBinding(LevelState level, GridPosition position, ECarrierType carrier, List<ValidationIssue> errors)
{
if (!level.InBounds(position) || level.GetProp(position) is not { Type: EPropType.Consumer } prop || prop.Carrier != carrier)
errors.Add(new($"Missing or invalid {carrier} consumer binding.", position));
if (level.RequiredFuelConsumers < 0 || level.RequiredCoolantConsumers < 0 || level.RequiredElectricityConsumers < 0)
errors.Add(new("Required consumer counts cannot be negative."));
}
private static void ValidateJunctions(LevelState level, List<ValidationIssue> errors)
@@ -117,111 +120,6 @@ public sealed class LevelValidator
errors.AddRange(junction.Errors.Select(error => new ValidationIssue(error, junction.Position)));
}
private static void ValidateRuleEvents(LevelState level, List<ValidationIssue> errors)
{
foreach (var ruleEvent in level.RuleEvents)
{
foreach (var predicate in ruleEvent.Predicates)
ValidateRulePredicate(level, predicate, errors);
foreach (var effect in ruleEvent.Effects)
ValidateRuleEffect(level, effect, errors);
}
}
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)
{
foreach (var carrier in Enum.GetValues<ECarrierType>())
@@ -243,12 +141,31 @@ public sealed class LevelValidator
{
var position = new GridPosition(x, y);
var prop = level.GetProp(position);
if (prop.Type == EPropType.Consumer && prop.SwitchState == EPropSwitchState.Enabled && !HasSourcePath(level, position, prop.Carrier))
warnings.Add(new("Enabled consumer is initially starved.", position));
if (prop.Type != EPropType.Consumer || prop.SwitchState != EPropSwitchState.Enabled)
continue;
var hasPresentNetwork = false;
foreach (var carrier in Enum.GetValues<ECarrierType>())
{
if (!level.GetUnderground(position, carrier).IsPresent)
continue;
hasPresentNetwork = true;
if (!HasSourcePath(level, position, carrier))
warnings.Add(new($"Enabled consumer has no {carrier} source path.", position));
}
if (!hasPresentNetwork)
warnings.Add(new("Enabled consumer has no underground network beneath it.", position));
}
}
}
private static bool IsWall(LevelState level, GridPosition position)
{
return level.InBounds(position) && level.GetTerrain(position) == ECellTerrain.Wall;
}
private static bool HasSourcePath(LevelState level, GridPosition start, ECarrierType carrier)
{
if (!level.GetUnderground(start, carrier).CarriesFlow)

View File

@@ -1,8 +0,0 @@
namespace ReactorMaintenance.Simulation;
public sealed record DoorState
{
public GridPosition A { get; init; } = new(0, 0);
public GridPosition B { get; init; } = new(0, 0);
public EDoorState State { get; init; } = EDoorState.Closed;
}

View File

@@ -6,5 +6,5 @@ public enum EForecastKind
ReactorReady,
ConsumerStarved,
HazardGrowth,
RuleEvent
StructuralIntegrity
}

View File

@@ -1,18 +0,0 @@
namespace ReactorMaintenance.Simulation;
public enum ERuleEffectKind
{
StartLeak,
WorsenLeak,
RepairNetworkCell,
DisableNetworkCell,
SetPropEnabled,
AddSurfaceHazard,
RemoveSurfaceHazard,
AddHeat,
RemoveHeat,
AddInventory,
RemoveInventory,
MarkTerminalLoss,
EmitWarning
}

View File

@@ -1,7 +0,0 @@
namespace ReactorMaintenance.Simulation;
public enum ERuleEventPhase
{
StartOfSimulation,
EndOfTurn
}

View File

@@ -1,17 +0,0 @@
namespace ReactorMaintenance.Simulation;
public enum ERulePredicateKind
{
TurnAtLeast,
LevelStateIs,
ReactorReadyIs,
ReactorLostIs,
ReactorWonIs,
PropStateAt,
ConsumerStateAt,
NetworkBandAt,
SurfaceBandAt,
RobotAt,
RobotInventoryAtLeast,
AllSeeingEyeUnlocked
}

View File

@@ -3,10 +3,8 @@
public sealed record GlobalState
{
public int Turn { get; init; }
public int ActionsRemaining { get; init; } = Balancing.Current.ActionsPerTurn;
public ELevelState LevelState { get; init; } = ELevelState.Stable;
public string Status { get; init; } = "STABLE";
public bool AllSeeingEyeUnlocked { get; init; }
public bool TerminalLoss { get; init; }
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
}

View File

@@ -16,10 +16,11 @@ public sealed record LevelState
public UndergroundCell[] Electricity { get; init; } = LevelStateFactory.CreateUnderground(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public SurfaceState[] Surface { get; init; } = LevelStateFactory.CreateSurface(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public PropState[] Props { get; init; } = LevelStateFactory.CreateProps(Balancing.Current.DefaultLevelWidth, Balancing.Current.DefaultLevelHeight);
public IReadOnlyList<DoorState> Doors { get; init; } = Array.Empty<DoorState>();
public IReadOnlyList<LeakState> Leaks { get; init; } = Array.Empty<LeakState>();
public IReadOnlyList<ReactorBinding> Reactors { get; init; } = Array.Empty<ReactorBinding>();
public IReadOnlyList<RuleEventState> RuleEvents { get; init; } = Array.Empty<RuleEventState>();
public IReadOnlyList<ReactorState> Reactors { get; init; } = Array.Empty<ReactorState>();
public int RequiredFuelConsumers { get; init; } = 1;
public int RequiredCoolantConsumers { get; init; } = 1;
public int RequiredElectricityConsumers { get; init; } = 1;
public RobotState Robot { get; init; } = new();
public GlobalState Global { get; init; } = new();
public IReadOnlyList<Forecast> Forecasts { get; init; } = Array.Empty<Forecast>();

View File

@@ -6,10 +6,24 @@ public sealed record PropState
public ECarrierType Carrier { get; init; }
public EPropSwitchState SwitchState { get; init; } = EPropSwitchState.Enabled;
public EConsumerServiceState ServiceState { get; init; } = EConsumerServiceState.Unknown;
public EConsumerServiceState FuelServiceState { get; init; } = EConsumerServiceState.Unknown;
public EConsumerServiceState CoolantServiceState { get; init; } = EConsumerServiceState.Unknown;
public EConsumerServiceState ElectricityServiceState { get; init; } = EConsumerServiceState.Unknown;
public int JunctionMode { get; init; }
public ERemedyType RemedyType { get; init; }
public bool Depleted { get; init; }
public int ReactorId { get; init; }
public EDoorState DoorState { get; init; } = EDoorState.Closed;
public bool IsEnabled => SwitchState == EPropSwitchState.Enabled;
public EConsumerServiceState ServiceStateFor(ECarrierType carrier)
{
return carrier switch {
ECarrierType.Fuel => FuelServiceState,
ECarrierType.Coolant => CoolantServiceState,
ECarrierType.Electricity => ElectricityServiceState,
_ => EConsumerServiceState.Unknown
};
}
}

View File

@@ -1,12 +0,0 @@
namespace ReactorMaintenance.Simulation;
public sealed record ReactorBinding
{
public int ReactorId { get; init; }
public GridPosition ControlPosition { get; init; } = new(0, 0);
public GridPosition FuelConsumerPosition { get; init; } = new(0, 0);
public GridPosition CoolantConsumerPosition { get; init; } = new(0, 0);
public GridPosition ElectricityConsumerPosition { get; init; } = new(0, 0);
public bool Ready { get; init; }
public bool Activated { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace ReactorMaintenance.Simulation;
public sealed record ReactorState
{
public int ReactorId { get; init; }
public GridPosition ControlPosition { get; init; } = new(0, 0);
public bool Ready { get; init; }
public bool Activated { get; init; }
}

View File

@@ -1,13 +0,0 @@
namespace ReactorMaintenance.Simulation;
public sealed record RuleEffect
{
public ERuleEffectKind Kind { get; init; }
public GridPosition Position { get; init; } = new(0, 0);
public GridPosition? AccessPosition { get; init; }
public ECarrierType Carrier { get; init; }
public ERemedyType Remedy { get; init; }
public float Amount { get; init; }
public EPropSwitchState PropSwitchState { get; init; }
public string Message { get; init; } = string.Empty;
}

View File

@@ -1,14 +0,0 @@
namespace ReactorMaintenance.Simulation;
public sealed record RuleEventState
{
public string Id { get; init; } = string.Empty;
public bool Enabled { get; init; } = true;
public bool Repeat { get; init; }
public bool Triggered { get; init; }
public int Priority { get; init; }
public ERuleEventPhase Phase { get; init; }
public IReadOnlyList<RulePredicate> Predicates { get; init; } = Array.Empty<RulePredicate>();
public IReadOnlyList<RuleEffect> Effects { get; init; } = Array.Empty<RuleEffect>();
public string ForecastText { get; init; } = string.Empty;
}

View File

@@ -1,18 +0,0 @@
namespace ReactorMaintenance.Simulation;
public sealed record RulePredicate
{
public ERulePredicateKind Kind { get; init; }
public GridPosition Position { get; init; } = new(0, 0);
public int ReactorId { get; init; }
public int Turn { get; init; }
public ELevelState LevelState { get; init; }
public EPropSwitchState PropSwitchState { get; init; }
public EConsumerServiceState ConsumerServiceState { get; init; }
public ECarrierType Carrier { get; init; }
public ENetworkValueKind NetworkValue { get; init; }
public ERemedyType Remedy { get; init; }
public EBand Band { get; init; }
public int InventoryCount { get; init; }
public bool BoolValue { get; init; }
}

View File

@@ -5,6 +5,7 @@ public sealed record UndergroundCell
public EUndergroundState State { get; init; }
public float Amount { get; init; }
public float Intensity { get; init; }
public int StructuralIntegrity { get; init; } = Balancing.Current.MaxStructuralIntegrity;
public bool IsPresent => State != EUndergroundState.Absent;
public bool CarriesFlow => State is EUndergroundState.Intact or EUndergroundState.Leaking;

View File

@@ -4,22 +4,27 @@ public sealed class SimulationEngine
{
public LevelState MoveRobot(LevelState level, GridPosition destination)
{
return PlayerActionSystem.MoveRobot(level, destination, SpendAction);
return PlayerActionSystem.MoveRobot(level, destination);
}
public LevelState InteractProp(LevelState level)
{
return PlayerActionSystem.InteractProp(level, SpendAction);
return PlayerActionSystem.InteractProp(level, ResolveStep);
}
public LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy)
{
return PlayerActionSystem.InteractLeak(level, carrier, useRemedy, SpendAction);
return PlayerActionSystem.InteractLeak(level, carrier, useRemedy, ResolveStep);
}
public LevelState ApplyHeatShield(LevelState level)
{
return PlayerActionSystem.ApplyHeatShield(level, SpendAction);
return PlayerActionSystem.ApplyHeatShield(level, ResolveStep);
}
private LevelState ResolveStep(LevelState level)
{
return ResolveStep(level, true);
}
public LevelState ActivateReactor(LevelState level)
@@ -29,45 +34,37 @@ public sealed class SimulationEngine
public LevelState EndTurn(LevelState level)
{
return ResolveTurn(level with { Global = level.Global with { ActionsRemaining = 0 } });
return ResolveStep(level);
}
public LevelState AdvanceTurn(LevelState level)
{
return ResolveTurn(level);
return ResolveStep(level);
}
public IReadOnlyList<Forecast> Forecast(LevelState level)
{
return ForecastSystem.Forecast(level, simulated => ResolveTurn(simulated, false));
return ForecastSystem.Forecast(level, simulated => ResolveStep(simulated, false));
}
private LevelState SpendAction(LevelState level)
{
var actions = Math.Max(0, level.Global.ActionsRemaining - 1);
var next = level with { Global = level.Global with { ActionsRemaining = actions } };
return actions == 0 ? ResolveTurn(next) : next;
}
private LevelState ResolveTurn(LevelState level, bool refreshForecasts = true)
private LevelState ResolveStep(LevelState level, bool refreshForecasts)
{
var report = m_Validator.Validate(level);
if (!report.IsValid)
return level with { Global = level.Global with { LevelState = ELevelState.Lost, Status = report.Errors[0].Message } };
var next = RuleEventSystem.Apply(level, ERuleEventPhase.StartOfSimulation);
var next = level;
next = NetworkPropagationSystem.Propagate(next);
next = ConsumerSystem.Resolve(next);
next = StructuralIntegritySystem.Resolve(next);
next = LeakSystem.Inject(next);
next = SurfaceInteractionSystem.Resolve(next);
next = RobotSafetySystem.Resolve(next);
next = ReactorSystem.DeriveState(next);
next = RuleEventSystem.Apply(next, ERuleEventPhase.EndOfTurn);
next = SurfaceInteractionSystem.AdvanceDurations(next);
next = next with {
Global = next.Global with {
Turn = next.Global.Turn + 1,
ActionsRemaining = Balancing.Current.ActionsPerTurn
Turn = next.Global.Turn + 1
}
};

View File

@@ -14,15 +14,59 @@ internal static class ConsumerSystem
if (prop.SwitchState == EPropSwitchState.Disabled)
{
props[index] = prop with { ServiceState = EConsumerServiceState.Disabled };
var disabledFuel = DisabledServiceStateFor(level, position, ECarrierType.Fuel);
var disabledCoolant = DisabledServiceStateFor(level, position, ECarrierType.Coolant);
var disabledElectricity = DisabledServiceStateFor(level, position, ECarrierType.Electricity);
props[index] = prop with {
ServiceState = Aggregate(disabledFuel, disabledCoolant, disabledElectricity),
FuelServiceState = disabledFuel,
CoolantServiceState = disabledCoolant,
ElectricityServiceState = disabledElectricity
};
continue;
}
var underground = level.GetUnderground(position, prop.Carrier);
var supplied = underground.Amount >= Balancing.Current.ConsumerRequiredAmount && underground.Intensity >= Balancing.Current.ConsumerRequiredIntensity;
props[index] = prop with { ServiceState = supplied ? EConsumerServiceState.Producing : EConsumerServiceState.Starved };
var fuel = ServiceStateFor(level, position, ECarrierType.Fuel);
var coolant = ServiceStateFor(level, position, ECarrierType.Coolant);
var electricity = ServiceStateFor(level, position, ECarrierType.Electricity);
props[index] = prop with {
ServiceState = Aggregate(fuel, coolant, electricity),
FuelServiceState = fuel,
CoolantServiceState = coolant,
ElectricityServiceState = electricity
};
}
return level with { Props = props };
}
private static EConsumerServiceState ServiceStateFor(LevelState level, GridPosition position, ECarrierType carrier)
{
var underground = level.GetUnderground(position, carrier);
if (!underground.IsPresent)
return EConsumerServiceState.Unknown;
var supplied = underground.Amount >= Balancing.Current.ConsumerRequiredAmount && underground.Intensity >= Balancing.Current.ConsumerRequiredIntensity;
return supplied ? EConsumerServiceState.Producing : EConsumerServiceState.Starved;
}
private static EConsumerServiceState DisabledServiceStateFor(LevelState level, GridPosition position, ECarrierType carrier)
{
return level.GetUnderground(position, carrier).IsPresent ? EConsumerServiceState.Disabled : EConsumerServiceState.Unknown;
}
private static EConsumerServiceState Aggregate(params EConsumerServiceState[] states)
{
var participating = states.Where(state => state != EConsumerServiceState.Unknown).ToArray();
if (participating.Length == 0)
return EConsumerServiceState.Unknown;
if (participating.Any(state => state == EConsumerServiceState.Starved))
return EConsumerServiceState.Starved;
if (participating.Any(state => state == EConsumerServiceState.Disabled))
return EConsumerServiceState.Disabled;
return EConsumerServiceState.Producing;
}
}

View File

@@ -44,15 +44,25 @@ internal static class ForecastSystem
foreach (var position in LevelTraversal.AllPositions(level))
{
var prop = level.GetProp(position);
if (prop.Type == EPropType.Consumer && prop.ServiceState == EConsumerServiceState.Starved)
forecasts.Add(new(EForecastKind.ConsumerStarved, position, turn, $"{prop.Carrier} consumer starved"));
if (prop.Type == EPropType.Consumer)
{
foreach (var carrier in Enum.GetValues<ECarrierType>())
{
if (prop.ServiceStateFor(carrier) == EConsumerServiceState.Starved)
forecasts.Add(new(EForecastKind.ConsumerStarved, position, turn, $"{carrier} consumer starved"));
}
}
foreach (var carrier in Enum.GetValues<ECarrierType>())
{
var underground = level.GetUnderground(position, carrier);
if (underground.IsPresent && underground.StructuralIntegrity <= Balancing.Current.StructuralIntegrityLeakThreshold)
forecasts.Add(new(EForecastKind.StructuralIntegrity, position, turn, $"{carrier} structural integrity failing"));
}
var surface = level.GetSurface(position);
if (SimulationBands.Fuel(surface.Fuel) == EBand.Critical || SimulationBands.Coolant(surface.Coolant) == EBand.Critical || SimulationBands.Electricity(surface.Electricity) == EBand.Critical || SimulationBands.Heat(surface.Heat) == EBand.Critical)
forecasts.Add(new(EForecastKind.HazardGrowth, position, turn, "Critical hazard"));
}
foreach (var ruleEvent in level.RuleEvents.Where(ruleEvent => ruleEvent.Enabled && !string.IsNullOrWhiteSpace(ruleEvent.ForecastText) && ruleEvent.Predicates.All(predicate => RuleEventSystem.PredicateMatches(level, predicate))))
forecasts.Add(new(EForecastKind.RuleEvent, null, turn, ruleEvent.ForecastText));
}
}

View File

@@ -2,23 +2,23 @@
internal static class PlayerActionSystem
{
public static LevelState MoveRobot(LevelState level, GridPosition destination, Func<LevelState, LevelState> spendAction)
public static LevelState MoveRobot(LevelState level, GridPosition destination)
{
if (!CanSpendAction(level) || !level.IsFloor(destination) || level.Robot.Position.ManhattanDistance(destination) != 1)
if (!CanAct(level) || !level.IsFloor(destination) || level.Robot.Position.ManhattanDistance(destination) != 1)
return Refuse(level, "MOVE BLOCKED");
return spendAction(level with {
return level with {
Robot = level.Robot with {
Position = destination,
HeatImmunitySteps = Math.Max(0, level.Robot.HeatImmunitySteps - 1)
}
});
};
}
public static LevelState InteractProp(LevelState level, Func<LevelState, LevelState> spendAction)
public static LevelState InteractProp(LevelState level, Func<LevelState, LevelState> resolveLengthyAction)
{
if (!CanSpendAction(level))
return Refuse(level, "NO ACTIONS");
if (!CanAct(level))
return Refuse(level, "NO CONTROL");
var position = level.Robot.Position;
var prop = level.GetProp(position);
@@ -28,20 +28,20 @@ internal static class PlayerActionSystem
var next = prop.Type switch {
EPropType.Flow or EPropType.Consumer => ToggleProp(level, position, prop),
EPropType.Junction => CycleJunctionMode(level, position, prop),
EPropType.Door => ToggleDoor(level, position),
EPropType.AllSeeingEyeTerminal => level with { Global = level.Global with { AllSeeingEyeUnlocked = true, Status = "ALL-SEEING-EYE ONLINE" } },
EPropType.Door => ToggleDoor(level, position, prop),
EPropType.AllSeeingEyeTerminal => level with { Global = level.Global with { Status = "ALL-SEEING-EYE AVAILABLE" } },
EPropType.RemedySupply => PickUpRemedy(level, position, prop),
EPropType.ReactorControl => ReactorSystem.Activate(level),
_ => level
};
return spendAction(next);
return prop.Type == EPropType.AllSeeingEyeTerminal || prop.Type == EPropType.ReactorControl ? next : resolveLengthyAction(next);
}
public static LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy, Func<LevelState, LevelState> spendAction)
public static LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy, Func<LevelState, LevelState> resolveLengthyAction)
{
if (!CanSpendAction(level))
return Refuse(level, "NO ACTIONS");
if (!CanAct(level))
return Refuse(level, "NO CONTROL");
var leakIndex = level.Leaks.ToList().FindIndex(leak => !leak.Repaired && leak.Carrier == carrier && leak.AccessPosition == level.Robot.Position);
if (leakIndex < 0)
@@ -49,15 +49,15 @@ internal static class PlayerActionSystem
var leak = level.Leaks[leakIndex];
var next = useRemedy ? ApplyElementRemedy(level, leak) : RepairLeak(level, leakIndex, leak);
return spendAction(next);
return resolveLengthyAction(next);
}
public static LevelState ApplyHeatShield(LevelState level, Func<LevelState, LevelState> spendAction)
public static LevelState ApplyHeatShield(LevelState level, Func<LevelState, LevelState> resolveLengthyAction)
{
if (!CanSpendAction(level) || level.Robot.HeatShields <= 0)
if (!CanAct(level) || level.Robot.HeatShields <= 0)
return Refuse(level, "NO HEAT SHIELD");
return spendAction(level with {
return resolveLengthyAction(level with {
Robot = level.Robot.Spend(ERemedyType.HeatShield) with { HeatImmunitySteps = Balancing.Current.HeatShieldSteps }
});
}
@@ -68,15 +68,10 @@ internal static class PlayerActionSystem
return level.SetProp(position, prop with { SwitchState = switchState });
}
private static LevelState ToggleDoor(LevelState level, GridPosition position)
private static LevelState ToggleDoor(LevelState level, GridPosition position, PropState prop)
{
var doors = level.Doors.ToArray();
var index = Array.FindIndex(doors, door => door.A == position || door.B == position);
if (index < 0)
return level;
doors[index] = doors[index] with { State = doors[index].State == EDoorState.Open ? EDoorState.Closed : EDoorState.Open };
return level with { Doors = doors };
var doorState = prop.DoorState == EDoorState.Open ? EDoorState.Closed : EDoorState.Open;
return level.SetProp(position, prop with { DoorState = doorState });
}
private static LevelState PickUpRemedy(LevelState level, GridPosition position, PropState prop)
@@ -91,7 +86,12 @@ internal static class PlayerActionSystem
{
var leaks = level.Leaks.ToArray();
leaks[leakIndex] = leak with { Repaired = true };
return level.SetUnderground(leak.UndergroundPosition, leak.Carrier, level.GetUnderground(leak.UndergroundPosition, leak.Carrier) with { State = EUndergroundState.Intact }) with { Leaks = leaks };
return level.SetUnderground(leak.UndergroundPosition, leak.Carrier, level.GetUnderground(leak.UndergroundPosition, leak.Carrier) with {
State = EUndergroundState.Intact,
StructuralIntegrity = Balancing.Current.MaxStructuralIntegrity
}) with {
Leaks = leaks
};
}
private static LevelState ApplyElementRemedy(LevelState level, LeakState leak)
@@ -121,9 +121,9 @@ internal static class PlayerActionSystem
return level.SetProp(position, prop with { JunctionMode = (prop.JunctionMode + 1) % ratios.Count });
}
private static bool CanSpendAction(LevelState level)
private static bool CanAct(LevelState level)
{
return level.Global.LevelState is not (ELevelState.Lost or ELevelState.Won) && level.Global.ActionsRemaining > 0;
return level.Global.LevelState is not (ELevelState.Lost or ELevelState.Won);
}
private static LevelState Refuse(LevelState level, string message)

View File

@@ -34,41 +34,54 @@ internal static class ReactorSystem
return level with { Reactors = reactors, Global = level.Global with { LevelState = ELevelState.Lost, TerminalLoss = true, Status = "REACTOR HEAT TERMINAL" } };
var hasCritical = level.Surface.Any(surface => SimulationBands.Fuel(surface.Fuel) == EBand.Critical || SimulationBands.Coolant(surface.Coolant) == EBand.Critical || SimulationBands.Electricity(surface.Electricity) == EBand.Critical || SimulationBands.Heat(surface.Heat) == EBand.Critical);
var hasCaution = hasCritical || level.Props.Any(prop => prop.ServiceState is EConsumerServiceState.Starved or EConsumerServiceState.Disabled) || level.Leaks.Any(leak => !leak.Repaired);
var hasCaution = hasCritical
|| !HasRequiredConsumers(level)
|| level.Props.Any(prop => prop.Type == EPropType.Consumer && HasConsumerTrouble(prop))
|| level.Leaks.Any(leak => !leak.Repaired);
var state = hasCritical ? ELevelState.Critical :
hasCaution ? ELevelState.Caution : ELevelState.Stable;
return level with { Reactors = reactors, Global = level.Global with { LevelState = state, Status = state.ToString().ToUpperInvariant() } };
}
public static bool MatchesReady(LevelState level, RulePredicate predicate)
private static bool IsReady(LevelState level, ReactorState reactor)
{
return level.Reactors.Any(reactor => MatchesId(reactor, predicate.ReactorId) && reactor.Ready) == predicate.BoolValue;
}
public static bool MatchesWon(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 static bool IsReady(LevelState level, ReactorBinding reactor)
{
return HasProducingConsumer(level, reactor.FuelConsumerPosition, ECarrierType.Fuel)
&& HasProducingConsumer(level, reactor.CoolantConsumerPosition, ECarrierType.Coolant)
&& HasProducingConsumer(level, reactor.ElectricityConsumerPosition, ECarrierType.Electricity)
return ReactorFeedsPresentAndProducing(level, reactor.ControlPosition)
&& ProducingConsumerCount(level, ECarrierType.Fuel) >= level.RequiredFuelConsumers
&& ProducingConsumerCount(level, ECarrierType.Coolant) >= level.RequiredCoolantConsumers
&& ProducingConsumerCount(level, ECarrierType.Electricity) >= level.RequiredElectricityConsumers
&& level.GetSurface(reactor.ControlPosition).Heat < Balancing.Current.TerminalHeat;
}
private static bool HasProducingConsumer(LevelState level, GridPosition position, ECarrierType carrier)
private static bool ReactorFeedsPresentAndProducing(LevelState level, GridPosition position)
{
return level.InBounds(position) && level.GetProp(position) is { Type: EPropType.Consumer, Carrier: var consumerCarrier, ServiceState: EConsumerServiceState.Producing } && consumerCarrier == carrier;
foreach (var carrier in Enum.GetValues<ECarrierType>())
{
var underground = level.GetUnderground(position, carrier);
if (underground.IsPresent && (underground.Amount <= 0 || underground.Intensity <= 0))
return false;
}
private static bool MatchesId(ReactorBinding reactor, int reactorId)
return true;
}
private static int ProducingConsumerCount(LevelState level, ECarrierType carrier)
{
return reactorId <= 0 || reactor.ReactorId == reactorId;
return LevelTraversal.AllPositions(level)
.Count(position => level.GetProp(position) is { Type: EPropType.Consumer } prop && prop.ServiceStateFor(carrier) == EConsumerServiceState.Producing);
}
private static bool HasRequiredConsumers(LevelState level)
{
return ProducingConsumerCount(level, ECarrierType.Fuel) >= level.RequiredFuelConsumers
&& ProducingConsumerCount(level, ECarrierType.Coolant) >= level.RequiredCoolantConsumers
&& ProducingConsumerCount(level, ECarrierType.Electricity) >= level.RequiredElectricityConsumers;
}
private static bool HasConsumerTrouble(PropState prop)
{
return prop.FuelServiceState is EConsumerServiceState.Starved or EConsumerServiceState.Disabled
|| prop.CoolantServiceState is EConsumerServiceState.Starved or EConsumerServiceState.Disabled
|| prop.ElectricityServiceState is EConsumerServiceState.Starved or EConsumerServiceState.Disabled;
}
private static LevelState Refuse(LevelState level, string message)

View File

@@ -1,73 +0,0 @@
namespace ReactorMaintenance.Simulation;
internal static class RuleEventSystem
{
public static LevelState Apply(LevelState level, ERuleEventPhase phase)
{
var next = level;
var events = level.RuleEvents.Select((ruleEvent, index) => (Event: ruleEvent, Index: index)).Where(item => item.Event.Enabled && item.Event.Phase == phase && (item.Event.Repeat || !item.Event.Triggered)).OrderBy(item => item.Event.Priority).ToArray();
var ruleEvents = next.RuleEvents.ToArray();
foreach (var item in events)
{
if (!item.Event.Predicates.All(predicate => PredicateMatches(next, predicate)))
continue;
foreach (var effect in item.Event.Effects)
next = ApplyEffect(next, effect);
ruleEvents[item.Index] = item.Event with { Triggered = true };
}
return next with { RuleEvents = ruleEvents };
}
public static bool PredicateMatches(LevelState level, RulePredicate predicate)
{
return predicate.Kind switch {
ERulePredicateKind.TurnAtLeast => level.Global.Turn >= predicate.Turn,
ERulePredicateKind.LevelStateIs => level.Global.LevelState == predicate.LevelState,
ERulePredicateKind.ReactorReadyIs => ReactorSystem.MatchesReady(level, predicate),
ERulePredicateKind.ReactorLostIs => level.Global.LevelState == ELevelState.Lost == predicate.BoolValue,
ERulePredicateKind.ReactorWonIs => ReactorSystem.MatchesWon(level, predicate),
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.NetworkBandAt => level.InBounds(predicate.Position) && SimulationBands.NetworkBand(level.GetUnderground(predicate.Position, predicate.Carrier), predicate.Carrier, predicate.NetworkValue) >= predicate.Band,
ERulePredicateKind.SurfaceBandAt => level.InBounds(predicate.Position) && SimulationBands.SurfaceBand(level.GetSurface(predicate.Position), predicate.Carrier) >= predicate.Band,
ERulePredicateKind.RobotAt => level.Robot.Position == predicate.Position,
ERulePredicateKind.RobotInventoryAtLeast => level.Robot.Count(predicate.Remedy) >= predicate.InventoryCount,
ERulePredicateKind.AllSeeingEyeUnlocked => level.Global.AllSeeingEyeUnlocked == predicate.BoolValue,
_ => false
};
}
private static LevelState ApplyEffect(LevelState level, RuleEffect effect)
{
return effect.Kind switch {
ERuleEffectKind.StartLeak => StartLeak(level, effect),
ERuleEffectKind.WorsenLeak => level.SetUnderground(effect.Position, effect.Carrier, level.GetUnderground(effect.Position, effect.Carrier) with { State = EUndergroundState.Leaking }),
ERuleEffectKind.RepairNetworkCell => level.SetUnderground(effect.Position, effect.Carrier, level.GetUnderground(effect.Position, effect.Carrier) with { State = EUndergroundState.Intact }),
ERuleEffectKind.DisableNetworkCell => level.SetUnderground(effect.Position, effect.Carrier, new()),
ERuleEffectKind.SetPropEnabled => level.SetProp(effect.Position, level.GetProp(effect.Position) with { SwitchState = effect.PropSwitchState }),
ERuleEffectKind.AddSurfaceHazard => level.SetSurface(effect.Position, SurfaceCarrierMath.AddCarrier(level.GetSurface(effect.Position), effect.Carrier, effect.Amount)),
ERuleEffectKind.RemoveSurfaceHazard => level.SetSurface(effect.Position, SurfaceCarrierMath.AddCarrier(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.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.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.EmitWarning => level with { Global = level.Global with { Warnings = [.. level.Global.Warnings, effect.Message] } },
_ => level
};
}
private static LevelState StartLeak(LevelState level, RuleEffect effect)
{
var leak = new LeakState {
Carrier = effect.Carrier,
UndergroundPosition = 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] };
}
}

View File

@@ -0,0 +1,82 @@
namespace ReactorMaintenance.Simulation;
internal static class StructuralIntegritySystem
{
public static LevelState Resolve(LevelState level)
{
foreach (var carrier in Enum.GetValues<ECarrierType>())
level = ResolveCarrier(level, carrier);
return level;
}
private static LevelState ResolveCarrier(LevelState level, ECarrierType carrier)
{
var layer = level.Layer(carrier).ToArray();
var leaks = level.Leaks.ToList();
foreach (var position in LevelTraversal.AllPositions(level))
{
var index = level.Index(position);
var cell = layer[index];
if (!cell.IsPresent)
continue;
var integrity = DegradeIntegrity(cell, carrier);
var state = cell.State;
if (state != EUndergroundState.Leaking && integrity <= Balancing.Current.StructuralIntegrityLeakThreshold && cell.Intensity > 0)
{
state = EUndergroundState.Leaking;
leaks = UpsertLeak(leaks, level, position, carrier);
}
layer[index] = cell with { State = state, StructuralIntegrity = integrity };
}
var next = carrier switch {
ECarrierType.Fuel => level with { Fuel = layer },
ECarrierType.Coolant => level with { Coolant = layer },
ECarrierType.Electricity => level with { Electricity = layer },
_ => throw new ArgumentOutOfRangeException(nameof(carrier), carrier, "Unsupported carrier.")
};
return next with { Leaks = leaks.ToArray() };
}
private static int DegradeIntegrity(UndergroundCell cell, ECarrierType carrier)
{
if (cell.StructuralIntegrity >= Balancing.Current.MaxStructuralIntegrity && cell.Intensity <= Balancing.Current.StructuralPressureThreshold(carrier))
return Balancing.Current.MaxStructuralIntegrity;
var overPressure = Math.Max(0, cell.Intensity - Balancing.Current.StructuralPressureThreshold(carrier));
if (overPressure <= 0 || cell.StructuralIntegrity >= Balancing.Current.MaxStructuralIntegrity)
return Math.Clamp(cell.StructuralIntegrity, 0, Balancing.Current.MaxStructuralIntegrity);
var damage = Math.Max(1, (int)Math.Ceiling(overPressure * Balancing.Current.StructuralIntegrityDamageScale));
return Math.Clamp(cell.StructuralIntegrity - damage, 0, Balancing.Current.MaxStructuralIntegrity);
}
private static List<LeakState> UpsertLeak(List<LeakState> leaks, LevelState level, GridPosition position, ECarrierType carrier)
{
var accessPosition = carrier == ECarrierType.Electricity
? position.Neighbors().FirstOrDefault(level.IsFloor)
: position;
if (accessPosition is null || !level.IsFloor(accessPosition))
return leaks;
var index = leaks.FindIndex(leak => leak.UndergroundPosition == position && leak.Carrier == carrier);
var leakState = new LeakState {
Carrier = carrier,
UndergroundPosition = position,
AccessPosition = accessPosition,
Repaired = false
};
if (index >= 0)
leaks[index] = leakState;
else
leaks.Add(leakState);
return leaks;
}
}

View File

@@ -280,7 +280,6 @@ public sealed partial class MainWindow
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(),
Reactors = m_Level.Reactors.Where(reactor => reactor.ControlPosition != position).ToArray()
};
m_Level = m_Level with { Forecasts = m_Simulation.Forecast(m_Level) };
@@ -384,6 +383,11 @@ public sealed partial class MainWindow
return m_Level.InBounds(position) ? m_Level.GetTerrain(position) : ECellTerrain.Wall;
}
private bool IsWall(GridPosition position)
{
return m_Level.InBounds(position) && m_Level.GetTerrain(position) == ECellTerrain.Wall;
}
private void DrawUnderground(CanvasDrawingSession drawing, CanvasLayout layout)
{
foreach (var position in AllPositions())
@@ -431,11 +435,19 @@ public sealed partial class MainWindow
private void DrawDoors(CanvasDrawingSession drawing, CanvasLayout layout)
{
foreach (var door in m_Level.Doors)
foreach (var position in AllPositions())
{
var centerA = Center(layout.CellRect(door.A));
var centerB = Center(layout.CellRect(door.B));
drawing.DrawLine((float)centerA.X, (float)centerA.Y, (float)centerB.X, (float)centerB.Y, door.State == EDoorState.Open ? Colors.LightGreen : Colors.OrangeRed, 5);
var prop = m_Level.GetProp(position);
if (prop.Type != EPropType.Door)
continue;
var rect = layout.CellRect(position);
var center = Center(rect);
var color = prop.DoorState == EDoorState.Open ? Colors.LightGreen : Colors.OrangeRed;
if (IsWall(new(position.X, position.Y - 1)) && IsWall(new(position.X, position.Y + 1)))
drawing.DrawLine((float)rect.Left, (float)center.Y, (float)rect.Right, (float)center.Y, color, 5);
else
drawing.DrawLine((float)center.X, (float)rect.Top, (float)center.X, (float)rect.Bottom, color, 5);
}
}
@@ -554,11 +566,11 @@ public sealed partial class MainWindow
LevelNameText.Text = m_Level.Name;
TurnText.Text = m_Level.Global.Turn.ToString(CultureInfo.InvariantCulture);
StatusText.Text = $"{m_Level.Global.LevelState}: {m_Level.Global.Status}";
GlobalText.Text = $"Actions: {m_Level.Global.ActionsRemaining}/{Balancing.Current.ActionsPerTurn}\n"
+ $"All-seeing-eye: {(m_Level.Global.AllSeeingEyeUnlocked ? "unlocked" : "locked")}\n"
+ $"Inventory: F {m_Level.Robot.FuelNeutralizers}, C {m_Level.Robot.CoolantNeutralizers}, E {m_Level.Robot.ElectricityNeutralizers}, H {m_Level.Robot.HeatShields}\n"
var doorCount = m_Level.Props.Count(prop => prop.Type == EPropType.Door);
GlobalText.Text = $"Inventory: F {m_Level.Robot.FuelNeutralizers}, C {m_Level.Robot.CoolantNeutralizers}, E {m_Level.Robot.ElectricityNeutralizers}, H {m_Level.Robot.HeatShields}\n"
+ $"Heat immunity steps: {m_Level.Robot.HeatImmunitySteps}\n"
+ $"Reactors: {m_Level.Reactors.Count}, Leaks: {m_Level.Leaks.Count}, Doors: {m_Level.Doors.Count}";
+ $"Required consumers F/C/E: {m_Level.RequiredFuelConsumers}/{m_Level.RequiredCoolantConsumers}/{m_Level.RequiredElectricityConsumers}\n"
+ $"Reactors: {m_Level.Reactors.Count}, Leaks: {m_Level.Leaks.Count}, Doors: {doorCount}";
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();
@@ -588,20 +600,9 @@ public sealed partial class MainWindow
private void ApplyDoorTool(GridPosition position)
{
if (!m_Level.IsFloor(position))
return;
if (m_PendingDoorCell is not { } pending)
{
m_PendingDoorCell = position;
ClearPendingEditorOperation();
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)
@@ -641,42 +642,17 @@ public sealed partial class MainWindow
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();
StatusText.Text = "Rule events were removed from level authoring.";
}
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();
StatusText.Text = "Rule events were removed from level authoring.";
}
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();
StatusText.Text = "Rule events were removed from level authoring.";
}
private void BindSelectedConsumer(ECarrierType carrier)
@@ -684,9 +660,7 @@ public sealed partial class MainWindow
if (m_SelectedCell is not { } position || m_SelectedReactorId is not { } reactorId)
return;
m_Level = LevelEditor.BindReactorConsumer(m_Level, reactorId, carrier, position);
RefreshForecasts();
RefreshInspector();
StatusText.Text = "Reactors now use required consumer counts instead of bindings.";
}
private string CellInspectionText(GridPosition position)
@@ -698,7 +672,7 @@ public sealed partial class MainWindow
var electricity = m_Level.GetUnderground(position, ECarrierType.Electricity);
return $"Position: {position.X},{position.Y}\n"
+ $"Terrain: {m_Level.GetTerrain(position)}\n"
+ $"Prop: {prop.Type} {prop.Carrier} {prop.SwitchState} {prop.ServiceState}\n"
+ $"Prop: {prop.Type} {prop.SwitchState} {prop.ServiceState}\n"
+ $"Fuel: {UndergroundText(fuel)}\n"
+ $"Coolant: {UndergroundText(coolant)}\n"
+ $"Electricity: {UndergroundText(electricity)}\n"
@@ -708,9 +682,6 @@ public sealed partial class MainWindow
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}.";
@@ -724,18 +695,14 @@ public sealed partial class MainWindow
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)}";
+ $"Control: {PositionText(reactor.ControlPosition)}\n"
+ $"Ready: {reactor.Ready}\n"
+ $"Required F/C/E: {m_Level.RequiredFuelConsumers}/{m_Level.RequiredCoolantConsumers}/{m_Level.RequiredElectricityConsumers}";
}
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}).";
return "Rule events were removed from level authoring.";
}
private static string PositionText(GridPosition position)
@@ -745,7 +712,7 @@ public sealed partial class MainWindow
private static string UndergroundText(UndergroundCell cell)
{
return $"{cell.State} amount {Format(cell.Amount)} intensity {Format(cell.Intensity)}";
return $"{cell.State} amount {Format(cell.Amount)} intensity {Format(cell.Intensity)} integrity {cell.StructuralIntegrity}";
}
private static string Format(float value)
@@ -762,26 +729,25 @@ public sealed partial class MainWindow
level = level.SetProp(new(2, 3), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
level = level.SetProp(new(2, 5), new() { Type = EPropType.Flow, Carrier = ECarrierType.Coolant });
level = level.SetProp(new(2, 7), new() { Type = EPropType.Flow, Carrier = ECarrierType.Electricity });
level = level.SetProp(new(5, 3), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Fuel });
level = level.SetProp(new(5, 5), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Coolant });
level = level.SetProp(new(5, 7), new() { Type = EPropType.Consumer, Carrier = ECarrierType.Electricity });
level = level.SetProp(new(5, 3), new() { Type = EPropType.Consumer });
level = level.SetProp(new(5, 5), new() { Type = EPropType.Consumer });
level = level.SetProp(new(5, 7), new() { Type = EPropType.Consumer });
level = level.SetProp(new(10, 5), new() { Type = EPropType.ReactorControl, ReactorId = 1 });
level = level.SetProp(new(11, 4), new() { Type = EPropType.AllSeeingEyeTerminal });
level = level.SetProp(new(11, 6), new() { Type = EPropType.RemedySupply, RemedyType = ERemedyType.HeatShield });
level = level.SetUnderground(new(4, 5), ECarrierType.Coolant, new() { State = EUndergroundState.Leaking }) with {
Leaks = [new() { Carrier = ECarrierType.Coolant, UndergroundPosition = new(4, 5), AccessPosition = new(4, 5) }],
Doors = [new() { A = new(8, 5), B = new(9, 5), State = EDoorState.Closed }],
Robot = new() { Position = new(10, 5) },
Reactors = [
new() {
ReactorId = 1,
ControlPosition = new(10, 5),
FuelConsumerPosition = new(5, 3),
CoolantConsumerPosition = new(5, 5),
ElectricityConsumerPosition = new(5, 7)
ControlPosition = new(10, 5)
}
]
};
level = level.SetTerrain(new(8, 4), ECellTerrain.Wall);
level = level.SetTerrain(new(8, 6), ECellTerrain.Wall);
level = level.SetProp(new(8, 5), new() { Type = EPropType.Door });
return level with { Forecasts = new SimulationEngine().Forecast(level) };
}
@@ -860,7 +826,7 @@ public sealed partial class MainWindow
{
return prop.Type switch {
EPropType.Flow => $"{CarrierShort(prop.Carrier)} SRC",
EPropType.Consumer => $"{CarrierShort(prop.Carrier)} CON",
EPropType.Consumer => "CON",
EPropType.Junction => $"J {prop.JunctionMode}",
EPropType.Door => "DOOR",
EPropType.AllSeeingEyeTerminal => "EYE",
@@ -975,7 +941,6 @@ public sealed partial class MainWindow
private void ClearPendingEditorOperation()
{
m_PendingDoorCell = null;
m_PendingElectricityLeakCell = null;
}
@@ -1005,7 +970,6 @@ public sealed partial class MainWindow
private LevelState m_Level;
private double m_PanX;
private double m_PanY;
private GridPosition? m_PendingDoorCell;
private GridPosition? m_PendingElectricityLeakCell;
private CanvasBitmap? m_RobotSprite;
private GridPosition? m_SelectedCell;

View File

@@ -3,18 +3,27 @@
public sealed class LevelEditorTests
{
[Fact]
public void DoorToolRequiresExplicitAdjacentEdgeSelection()
public void DoorToolPlacesSingleFloorDoorProp()
{
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));
var next = LevelEditor.Apply(level, new(2, 2), new() { Tool = EEditorTool.Door });
Assert.Equal(EPropType.Door, withDoorProp.GetProp(new(2, 2)).Type);
Assert.Empty(withDoorProp.Doors);
Assert.Single(withDoorEdge.Doors);
Assert.Equal(withDoorEdge.Doors, rejected.Doors);
Assert.Equal(EPropType.Door, next.GetProp(new(2, 2)).Type);
Assert.Equal(EDoorState.Closed, next.GetProp(new(2, 2)).DoorState);
}
[Fact]
public void ConsumerToolPlacesCarrierAgnosticConsumer()
{
var level = LevelState.Create("Consumer editor", 6, 6);
var next = LevelEditor.Apply(level, new(2, 2), new() { Tool = EEditorTool.Consumer, Carrier = ECarrierType.Fuel });
Assert.Equal(EPropType.Consumer, next.GetProp(new(2, 2)).Type);
Assert.Equal(EConsumerServiceState.Unknown, next.GetProp(new(2, 2)).FuelServiceState);
Assert.Equal(EConsumerServiceState.Unknown, next.GetProp(new(2, 2)).CoolantServiceState);
Assert.Equal(EConsumerServiceState.Unknown, next.GetProp(new(2, 2)).ElectricityServiceState);
}
[Fact]
@@ -33,44 +42,14 @@ public sealed class LevelEditorTests
}
[Fact]
public void ReactorBindingUpdatesOnlyMatchingCarrierConsumer()
public void ReactorControlToolCreatesUnboundReactorState()
{
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 level = LevelState.Create("Reactor editor", 6, 6);
var bound = LevelEditor.BindReactorConsumer(level, 1, ECarrierType.Fuel, new(2, 1));
var rejected = LevelEditor.BindReactorConsumer(bound, 1, ECarrierType.Fuel, new(3, 1));
var next = LevelEditor.Apply(level, new(2, 2), new() { Tool = EEditorTool.ReactorControl });
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);
Assert.Single(next.Reactors);
Assert.Equal(new(2, 2), next.Reactors[0].ControlPosition);
Assert.Equal(1, next.Reactors[0].ReactorId);
}
}

View File

@@ -3,19 +3,31 @@
public sealed class SimulationEngineTests
{
[Fact]
public void NetworkPropagationSuppliesBoundConsumersAndReadiesReactor()
public void NetworkPropagationSuppliesConsumerServicesAndReadiesReactor()
{
var level = BuildReadyLevel();
var next = m_Engine.AdvanceTurn(level);
var consumer = next.GetProp(new(3, 3));
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(EConsumerServiceState.Producing, consumer.FuelServiceState);
Assert.Equal(EConsumerServiceState.Producing, consumer.CoolantServiceState);
Assert.Equal(EConsumerServiceState.Producing, consumer.ElectricityServiceState);
Assert.Equal(ELevelState.Ready, next.Global.LevelState);
Assert.Contains(next.Forecasts, forecast => forecast.Kind == EForecastKind.ReactorReady);
}
[Fact]
public void ReactorNeedsPositiveFlowOnlyForNetworksBeneathControl()
{
var level = BuildReadyLevel();
level = level.SetUnderground(new(5, 3), ECarrierType.Fuel, new() { State = EUndergroundState.Intact });
var next = m_Engine.AdvanceTurn(level);
Assert.NotEqual(ELevelState.Ready, next.Global.LevelState);
}
[Fact]
public void ReactorActivatesOnlyAtReadyControl()
{
@@ -30,19 +42,110 @@ public sealed class SimulationEngineTests
}
[Fact]
public void LeakingUndergroundCellInjectsMatchingSurfaceHazard()
public void DisabledConsumerReportsDisabledOnlyForNetworksBeneathIt()
{
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() { Carrier = ECarrierType.Fuel, UndergroundPosition = new(2, 2), AccessPosition = new(2, 2) }]
var level = LevelState.Create("Disabled", 6, 6);
level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, new() { State = EUndergroundState.Intact });
level = level.SetProp(new(2, 2), new() { Type = EPropType.Consumer, SwitchState = EPropSwitchState.Disabled });
var next = m_Engine.AdvanceTurn(level);
var consumer = next.GetProp(new(2, 2));
Assert.Equal(EConsumerServiceState.Disabled, consumer.FuelServiceState);
Assert.Equal(EConsumerServiceState.Unknown, consumer.CoolantServiceState);
Assert.Equal(EConsumerServiceState.Unknown, consumer.ElectricityServiceState);
Assert.Equal(EConsumerServiceState.Disabled, consumer.ServiceState);
}
[Fact]
public void MovementIsQuickAndDoesNotResolveSimulationStep()
{
var level = LevelState.Create("Quick", 6, 6) with {
Robot = new() { Position = new(1, 1) }
};
level = level.SetProp(new(2, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
var next = m_Engine.MoveRobot(level, new(2, 1));
Assert.Equal(new(2, 1), next.Robot.Position);
Assert.Equal(0, next.Global.Turn);
}
[Fact]
public void DoorInteractionIsLengthyAndResolvesSimulationStep()
{
var level = DoorLevel();
level = level with { Robot = new() { Position = new(3, 2) } };
var next = m_Engine.InteractProp(level);
Assert.Equal(EDoorState.Open, next.GetProp(new(3, 2)).DoorState);
Assert.Equal(1, next.Global.Turn);
}
[Fact]
public void ClosedInferredDoorBlocksAdjacentHeatFlow()
{
var level = DoorLevel();
level = level.SetSurface(new(3, 2), new() { Heat = 8 });
var next = m_Engine.AdvanceTurn(level);
Assert.Equal(0, next.GetSurface(new(4, 2)).Heat);
}
[Fact]
public void StructuralIntegrityCreatesLeakWhenWeakCellHasPositivePressure()
{
var level = LevelState.Create("Integrity leak", 6, 6);
level = AddLine(level, ECarrierType.Fuel, new(1, 2), new(2, 2));
level = level.SetProp(new(1, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, level.GetUnderground(new(2, 2), ECarrierType.Fuel) with {
StructuralIntegrity = Balancing.Current.StructuralIntegrityLeakThreshold
});
var next = m_Engine.AdvanceTurn(level);
Assert.Equal(EUndergroundState.Leaking, next.GetUnderground(new(2, 2), ECarrierType.Fuel).State);
Assert.Contains(next.Leaks, leak => leak.Carrier == ECarrierType.Fuel && leak.UndergroundPosition == new GridPosition(2, 2));
Assert.True(next.GetSurface(new(2, 2)).Fuel > 0);
}
[Fact]
public void HighPressureWorsensNonMaxStructuralIntegrity()
{
var level = LevelState.Create("Integrity damage", 6, 6);
level = AddLine(level, ECarrierType.Fuel, new(1, 2), new(2, 2));
level = level.SetProp(new(1, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, level.GetUnderground(new(2, 2), ECarrierType.Fuel) with {
StructuralIntegrity = Balancing.Current.MaxStructuralIntegrity - 1
});
var next = m_Engine.AdvanceTurn(level);
Assert.True(next.GetUnderground(new(2, 2), ECarrierType.Fuel).StructuralIntegrity < Balancing.Current.MaxStructuralIntegrity - 1);
}
[Fact]
public void RepairingLeakRestoresStructuralIntegrity()
{
var level = LevelState.Create("Repair", 6, 6);
level = level.SetUnderground(new(2, 2), ECarrierType.Fuel, new() {
State = EUndergroundState.Leaking,
Amount = 5,
Intensity = 5,
StructuralIntegrity = 0
}) with {
Robot = new() { Position = new(2, 2) },
Leaks = [new() { Carrier = ECarrierType.Fuel, UndergroundPosition = new(2, 2), AccessPosition = new(2, 2) }]
};
var next = m_Engine.InteractLeak(level, ECarrierType.Fuel, false);
Assert.True(next.Leaks[0].Repaired);
Assert.Equal(EUndergroundState.Intact, next.GetUnderground(new(2, 2), ECarrierType.Fuel).State);
Assert.Equal(Balancing.Current.MaxStructuralIntegrity, next.GetUnderground(new(2, 2), ECarrierType.Fuel).StructuralIntegrity);
}
[Fact]
public void ElementRemedyClearsHazardAndBlocksImmediateReentry()
{
@@ -60,19 +163,6 @@ public sealed class SimulationEngineTests
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() { 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()
{
@@ -122,217 +212,19 @@ public sealed class SimulationEngineTests
}
[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()
{
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() {
Phase = ERuleEventPhase.EndOfTurn,
ForecastText = "containment failure",
Predicates = [new() { Kind = ERulePredicateKind.TurnAtLeast, Turn = 0 }],
Effects = [new() { 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() {
Phase = ERuleEventPhase.EndOfTurn,
Predicates = [new() { Kind = ERulePredicateKind.NetworkBandAt, Position = new(3, 2), Carrier = ECarrierType.Fuel, NetworkValue = ENetworkValueKind.Amount, Band = EBand.Critical }],
Effects = [new() { 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() {
Phase = ERuleEventPhase.EndOfTurn,
Predicates = [new() { Kind = ERulePredicateKind.ReactorReadyIs, ReactorId = 1, BoolValue = true }],
Effects = [new() { 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() {
Phase = ERuleEventPhase.StartOfSimulation,
Predicates = [new() { Kind = ERulePredicateKind.RobotInventoryAtLeast, Remedy = ERemedyType.FuelNeutralizer, InventoryCount = 1 }],
Effects = [new() { 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() { A = new(2, 2), B = new(1, 2), State = EDoorState.Closed },
new() { A = new(2, 2), B = new(3, 2), State = EDoorState.Closed },
new() { A = new(2, 2), B = new(2, 1), State = EDoorState.Closed },
new() { A = new(2, 2), B = new(2, 3), State = EDoorState.Closed }
],
RuleEvents = [
new() {
Phase = ERuleEventPhase.StartOfSimulation,
Predicates = [new() { Kind = ERulePredicateKind.TurnAtLeast, Turn = 0 }],
Effects = [
new() { Kind = ERuleEffectKind.RemoveSurfaceHazard, Position = new(2, 2), Carrier = ECarrierType.Fuel, Amount = 2 },
new() { Kind = ERuleEffectKind.RemoveHeat, Position = new(2, 2), Amount = 3 },
new() { 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() {
Phase = ERuleEventPhase.StartOfSimulation,
Predicates = [new() { Kind = ERulePredicateKind.TurnAtLeast, Turn = 0 }],
Effects = [new() { 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() {
Predicates = [new() { Kind = ERulePredicateKind.PropStateAt, Position = new(1, 1) }],
Effects = [
new() { Kind = ERuleEffectKind.AddSurfaceHazard, Position = new(2, 2), Carrier = ECarrierType.Fuel, Amount = 1 },
new() { 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()
public void ValidatorRejectsInvalidDoorGeometryAndWallHazards()
{
var level = LevelState.Create("Invalid", 6, 6);
level = level.SetTerrain(new(2, 2), ECellTerrain.Wall);
level = level with {
Surface = level.Surface.ToArray(),
Reactors = [new() { 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 };
level = level.SetProp(new(2, 2), new() { Type = EPropType.Door });
level = level.SetTerrain(new(4, 4), ECellTerrain.Wall);
level = level with { Surface = level.Surface.ToArray() };
level.Surface[level.Index(new(4, 4))] = new() { Heat = 1 };
var report = new LevelValidator().Validate(level);
Assert.False(report.IsValid);
Assert.Contains(report.Errors, error => error.Message.Contains("Door must be surrounded", StringComparison.Ordinal));
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]
@@ -343,36 +235,27 @@ public sealed class SimulationEngineTests
var json = LevelSerializer.Serialize(level);
var loaded = LevelSerializer.Deserialize(json);
Assert.Contains("\"Version\": 2", json);
Assert.Contains("\"Version\": 3", json);
Assert.Equal(level.Name, loaded.Name);
Assert.Equal(EPropType.Flow, loaded.GetProp(new(2, 2)).Type);
Assert.Equal(EPropType.Consumer, loaded.GetProp(new(3, 3)).Type);
Assert.Equal(level.RequiredFuelConsumers, loaded.RequiredFuelConsumers);
}
[Fact]
public void LevelSerializationRoundTripsRuleEventsDoorsAndElectricityLeakFaces()
public void LevelSerializationRoundTripsPropDoorsAndElectricityLeakFaces()
{
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" }]
}
]
Leaks = [new() { Carrier = ECarrierType.Electricity, UndergroundPosition = new(6, 4), AccessPosition = new(6, 3) }]
};
level = DoorLevel(level);
var loaded = LevelSerializer.Deserialize(LevelSerializer.Serialize(level));
Assert.Single(loaded.Doors);
Assert.Equal(EPropType.Door, loaded.GetProp(new(3, 2)).Type);
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]
@@ -380,43 +263,45 @@ public sealed class SimulationEngineTests
{
var json = """
{
"Version": 1,
"Version": 2,
"Level": {}
}
""";
var exception = Assert.Throws<InvalidOperationException>(() => LevelSerializer.Deserialize(json));
Assert.Contains("Unsupported level file version 1", exception.Message);
Assert.Contains("Unsupported level file version 2", 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.Fuel, new(2, 3), new(3, 3));
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 = AddLine(level, ECarrierType.Electricity, new(2, 3), new(3, 3));
level = level.SetProp(new(2, 3), new() { Type = EPropType.Flow, Carrier = ECarrierType.Fuel });
level = level.SetProp(new(2, 2), new() { Type = EPropType.Flow, Carrier = ECarrierType.Coolant });
level = level.SetUnderground(new(2, 2), ECarrierType.Coolant, new() { State = EUndergroundState.Intact });
level = level.SetUnderground(new(2, 3), ECarrierType.Coolant, new() { State = EUndergroundState.Intact });
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.SetUnderground(new(2, 4), ECarrierType.Electricity, new() { State = EUndergroundState.Intact });
level = level.SetUnderground(new(2, 3), ECarrierType.Electricity, new() { State = EUndergroundState.Intact });
level = level.SetProp(new(3, 3), new() { Type = EPropType.Consumer });
level = level.SetProp(new(5, 3), new() { Type = EPropType.ReactorControl, ReactorId = 1 });
return level with {
Robot = new() { Position = new(5, 3) },
Reactors = [
new() {
ReactorId = 1,
ControlPosition = new(5, 3),
FuelConsumerPosition = new(3, 2),
CoolantConsumerPosition = new(3, 3),
ElectricityConsumerPosition = new(3, 4)
}
]
Reactors = [new() { ReactorId = 1, ControlPosition = new(5, 3) }]
};
}
private static LevelState DoorLevel(LevelState? seed = null)
{
var level = seed ?? LevelState.Create("Door", 6, 6);
level = level.SetTerrain(new(3, 1), ECellTerrain.Wall);
level = level.SetTerrain(new(3, 3), ECellTerrain.Wall);
return level.SetProp(new(3, 2), new() { Type = EPropType.Door, DoorState = EDoorState.Closed });
}
private static LevelState AddLine(LevelState level, ECarrierType carrier, GridPosition a, GridPosition b)
{
level = level.SetUnderground(a, carrier, new() { State = EUndergroundState.Intact });
@@ -435,13 +320,5 @@ public sealed class SimulationEngineTests
return level.SetProp(new(2, 3), new() { Type = EPropType.Junction, Carrier = ECarrierType.Fuel, JunctionMode = 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();
}