From ad5445b09dc3936f0a80a174d1c0248fa5955c91 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 14 May 2026 11:04:36 +0200 Subject: [PATCH] Complete Godot pulse UX integration --- TASKS.md | 11 +- .../Controls/CellInspector.cs | 67 +++++++- .../Controls/ForecastList.cs | 12 ++ .../Controls/GridViewport.cs | 17 ++ .../Data/GameSession.cs | 35 +++- .../Screens/LevelScreen.cs | 160 +++++++++++++++--- .../SimulationEngine.cs | 56 +++++- 7 files changed, 315 insertions(+), 43 deletions(-) diff --git a/TASKS.md b/TASKS.md index 2a9677f..37db963 100644 --- a/TASKS.md +++ b/TASKS.md @@ -5,7 +5,7 @@ This backlog tracks what must change so the implementation matches `docs/design. ## Audit Snapshot - Current simulation tests pass: `60/60` in `tests/ReactorMaintenance.Simulation.Tests`. -- Godot has a usable UX scaffold and grid renderer; the full pulse playback, terminal-gated layer controls, and campaign content pass remain in later tasks. +- Godot level play includes pulse step playback, terminal-gated layer controls, campaign loading, and in-game level editing with JSON save. - Campaign data follows the tutorial plus six-group campaign from `docs/CAMPAIGN.md`. - Unrelated Godot metadata or generated `.uid` files are not part of this backlog unless a later implementation task intentionally touches them. @@ -160,20 +160,20 @@ This backlog tracks what must change so the implementation matches `docs/design. - Use `PulseAdvanced` or equivalent instead of `TurnAdvanced`. - Ensure accepted no-op powered-prop interactions still notify pulse playback. - Ensure refused invalid actions do not mutate state or trigger pulse playback. -- [ ] Animate `Pulse` playback as a short cascade of `Step`s. +- [x] Animate `Pulse` playback as a short cascade of `Step`s. - Show leak growth, sprinkler discharge, evaporation, quenching, ignition, wet conduction, and readiness updates. - Disable conflicting inputs during playback. - End playback on the final post-pulse decision state. -- [ ] Gate underground layer controls and forecasts. +- [x] Gate underground layer controls and forecasts. - Hide or disable layer toggles unless the robot is at an active and powered `AllSeeingEyeTerminal`. - Show why terminal access is unavailable when unpowered or away from the terminal. - Use `Pulse +N` wording in forecast UI. -- [ ] Update grid rendering and inspector text. +- [x] Update grid rendering and inspector text. - Render `Water` separately from underground `water`. - Render `Unsafe` cells with a distinct movement warning treatment. - Render isolation valve state, sprinkler control state, wall-mounted sprinkler outlet, powered door state, and powered terminal state. - Inspector should display visible surface values, prop state, consumer per-carrier state, and underground values only when terminal access allows it. -- [ ] Update action affordances. +- [x] Update action affordances. - Movement remains direct and quick. - Prop interactions should explain unavailable power, invalid position, missing remedy, depleted supply, and reactor-not-ready causes. - Disabled actions remain inspectable so players can understand why an action is unavailable. @@ -183,7 +183,6 @@ This backlog tracks what must change so the implementation matches `docs/design. - [ ] Add concise pulse-result feedback for major outcomes: isolated leak, restored pressure, downstream starvation, reactor ready, wet-electric risk, and terminal heat danger. - [ ] Add campaign completion flow for the final Group 6 level. - [ ] Add loading and malformed-level error states for campaign level loading. -- [ ] Verify Win2D editor export and Godot loader compatibility for the new schema. - [ ] Revisit art labels/icons for `Water`, `Unsafe`, powered props, and sprinkler controls after mechanics are implemented. ## Verification Rules diff --git a/src/ReactorMaintenance.Godot/Controls/CellInspector.cs b/src/ReactorMaintenance.Godot/Controls/CellInspector.cs index d8cd9c9..ff6514d 100644 --- a/src/ReactorMaintenance.Godot/Controls/CellInspector.cs +++ b/src/ReactorMaintenance.Godot/Controls/CellInspector.cs @@ -23,19 +23,76 @@ public partial class CellInspector : PanelContainer m_Text.AutowrapMode = TextServer.AutowrapMode.WordSmart; } - public void SetCellInfo(GridPosition? position, ECellTerrain terrain, EPropType prop, EConsumerServiceState serviceState, float fuelHazard, float waterHazard, float electricityHazard, float heatHazard) + public void SetCellInfo(LevelState? level, GridPosition? position, bool canSeeUnderground) { var pos = position ?? new(-1, -1); var sb = new StringBuilder(); sb.AppendLine($"Selected Cell: {pos.X},{pos.Y}"); + if (level is null || position is null || !level.InBounds(pos)) + { + m_Text.Text = sb.ToString(); + return; + } + + var terrain = level.GetTerrain(pos); + var prop = level.GetProp(pos); + var surface = level.GetSurface(pos); sb.AppendLine($"Terrain: {terrain}"); - sb.AppendLine($"Prop: {prop}"); - if (prop != EPropType.None) - sb.AppendLine($"Service: {serviceState}"); - sb.AppendLine($"Hazards: {FormatHazards(fuelHazard, waterHazard, electricityHazard, heatHazard)}"); + sb.AppendLine($"Prop: {FormatProp(prop)}"); + if (prop.Type != EPropType.None) + sb.AppendLine($"Service: {FormatService(prop)}"); + sb.AppendLine($"Surface: {FormatHazards(surface.Fuel, surface.Water, surface.Electricity, surface.Heat)}"); + sb.AppendLine(surface.IsUnsafe() ? "Movement: Unsafe" : "Movement: Safe"); + + if (canSeeUnderground) + sb.AppendLine($"Underground: {FormatUnderground(level, pos)}"); + else + sb.AppendLine("Underground: terminal access required"); m_Text.Text = sb.ToString(); } + private static string FormatProp(PropState prop) + { + return prop.Type switch { + EPropType.Flow => $"{prop.Carrier} Flow {prop.SwitchState}", + EPropType.Consumer => $"Consumer {prop.SwitchState}", + EPropType.IsolationValve => $"{prop.Carrier} Valve {(prop.IsOpen ? "Open" : "Closed")}", + EPropType.SprinklerControl => $"Sprinkler Control {prop.SwitchState}", + EPropType.SprinklerValve => $"Sprinkler Valve -> {FormatPosition(prop.OutletPosition)}", + EPropType.Door => $"Door {prop.DoorState}", + EPropType.AllSeeingEyeTerminal => prop.Active ? "All-Seeing-Eye Active" : "All-Seeing-Eye", + EPropType.ReactorControl => $"Reactor Control {prop.ReactorId}", + EPropType.RemedySupply => prop.Depleted ? $"{prop.RemedyType} Empty" : $"{prop.RemedyType} Supply", + _ => prop.Type.ToString() + }; + } + + private static string FormatService(PropState prop) + { + if (prop.Type == EPropType.Consumer) + return $"fuel {prop.FuelServiceState}, water {prop.WaterServiceState}, electricity {prop.ElectricityServiceState}"; + + return prop.ServiceState.ToString(); + } + + private static string FormatUnderground(LevelState level, GridPosition position) + { + var parts = new List(); + foreach (var carrier in Enum.GetValues()) + { + var cell = level.GetUnderground(position, carrier); + if (cell.IsPresent) + parts.Add($"{carrier} {cell.State} {cell.Amount:F1}/{cell.Intensity:F1}"); + } + + return parts.Count > 0 ? string.Join(", ", parts) : "none"; + } + + private static string FormatPosition(GridPosition? position) + { + return position is { } p ? $"{p.X},{p.Y}" : "unlinked"; + } + private static string FormatHazards(float fuel, float water, float electricity, float heat) { var parts = new List(); diff --git a/src/ReactorMaintenance.Godot/Controls/ForecastList.cs b/src/ReactorMaintenance.Godot/Controls/ForecastList.cs index fbcf851..21a76fa 100644 --- a/src/ReactorMaintenance.Godot/Controls/ForecastList.cs +++ b/src/ReactorMaintenance.Godot/Controls/ForecastList.cs @@ -28,6 +28,18 @@ public partial class ForecastList : PanelContainer } } + public void SetUnavailable(string reason) + { + foreach (var child in m_Items.GetChildren()) + child.QueueFree(); + + m_Items.AddChild(new Label { Text = "Forecasts" }); + m_Items.AddChild(new Label { + Text = reason, + AutowrapMode = TextServer.AutowrapMode.WordSmart + }); + } + private static string FormatForecast(Forecast forecast) { var pos = forecast.Position; diff --git a/src/ReactorMaintenance.Godot/Controls/GridViewport.cs b/src/ReactorMaintenance.Godot/Controls/GridViewport.cs index bbe565e..8a29a0a 100644 --- a/src/ReactorMaintenance.Godot/Controls/GridViewport.cs +++ b/src/ReactorMaintenance.Godot/Controls/GridViewport.cs @@ -42,6 +42,7 @@ public partial class GridViewport : Control DrawTerrain(layout, SurfaceOpacity()); DrawUnderground(layout); DrawSurfaceHazards(layout, SurfaceOpacity()); + DrawUnsafeWarnings(layout, SurfaceOpacity()); DrawDoors(layout, SurfaceOpacity()); DrawProps(layout, SurfaceOpacity()); DrawLeaks(layout, SurfaceOpacity()); @@ -206,6 +207,21 @@ public partial class GridViewport : Control } } + private void DrawUnsafeWarnings(SGridLayout layout, float opacity) + { + if (m_LevelState is null) + return; + + foreach (var position in AllPositions().Where(m_LevelState.IsFloor)) + { + if (!m_LevelState.GetSurface(position).IsUnsafe()) + continue; + + var rect = Inset(layout.CellRect(position), 0.08f); + DrawRect(rect, WithOpacity(c_UnsafeColor, opacity), false, Math.Max(2, layout.CellSize * 0.07f)); + } + } + private void FillHazard(Rect2 rect, float amount, Color color, float inset, float opacity, float caution, float critical) { var overlayOpacity = SurfaceOverlayOpacity(amount, caution, critical); @@ -628,6 +644,7 @@ public partial class GridViewport : Control private static readonly Color c_ReachableColor = new(0.78f, 0.96f, 0.84f, 0.20f); private static readonly Color c_ReadyColor = new(0.46f, 0.95f, 0.52f); private static readonly Color c_SelectedColor = new(1.0f, 1.0f, 1.0f, 0.95f); + private static readonly Color c_UnsafeColor = new(1.0f, 0.9f, 0.25f, 0.9f); private bool m_IsPanning; private LevelState? m_LevelState; diff --git a/src/ReactorMaintenance.Godot/Data/GameSession.cs b/src/ReactorMaintenance.Godot/Data/GameSession.cs index 81477c6..3cd12c3 100644 --- a/src/ReactorMaintenance.Godot/Data/GameSession.cs +++ b/src/ReactorMaintenance.Godot/Data/GameSession.cs @@ -37,7 +37,16 @@ public sealed class GameSession if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won) return false; - LevelState = m_Engine.InteractProp(LevelState); + var pulse = LevelState.Global.Pulse; + var trace = m_Engine.InteractPropWithPulseTrace(LevelState); + LevelState = trace.FinalState; + LastPulseSteps = trace.Steps; + if (LevelState.Global.Pulse == pulse) + { + LevelStateChanged?.Invoke(this); + return false; + } + OnPulseAdvanced(); return true; } @@ -47,7 +56,16 @@ public sealed class GameSession if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won) return false; - LevelState = m_Engine.InteractLeak(LevelState, carrier, useRemedy); + var pulse = LevelState.Global.Pulse; + var trace = m_Engine.InteractLeakWithPulseTrace(LevelState, carrier, useRemedy); + LevelState = trace.FinalState; + LastPulseSteps = trace.Steps; + if (LevelState.Global.Pulse == pulse) + { + LevelStateChanged?.Invoke(this); + return false; + } + OnPulseAdvanced(); return true; } @@ -57,7 +75,16 @@ public sealed class GameSession if (LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won) return false; - LevelState = m_Engine.ApplyHeatShield(LevelState); + var pulse = LevelState.Global.Pulse; + var trace = m_Engine.ApplyHeatShieldWithPulseTrace(LevelState); + LevelState = trace.FinalState; + LastPulseSteps = trace.Steps; + if (LevelState.Global.Pulse == pulse) + { + LevelStateChanged?.Invoke(this); + return false; + } + OnPulseAdvanced(); return true; } @@ -80,6 +107,7 @@ public sealed class GameSession public void Retry() { LevelState = m_StartSnapshot; + LastPulseSteps = Array.Empty(); LevelStateChanged?.Invoke(this); } @@ -122,6 +150,7 @@ public sealed class GameSession public GridPosition RobotPosition => LevelState.Robot.Position; public GlobalState GlobalState => LevelState.Global; public IReadOnlyList Forecasts => LevelState.Forecasts; + public IReadOnlyList LastPulseSteps { get; private set; } = Array.Empty(); public IReadOnlyList Leaks => LevelState.Leaks; public string LevelPath { get; private set; } = string.Empty; public IReadOnlyList Reactors => LevelState.Reactors; diff --git a/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs b/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs index 0f892ef..d742933 100644 --- a/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs +++ b/src/ReactorMaintenance.Godot/Screens/LevelScreen.cs @@ -2,6 +2,7 @@ using ReactorMaintenance.Godot.Controls; using ReactorMaintenance.Godot.Data; using ReactorMaintenance.Simulation; +using Timer = Godot.Timer; namespace ReactorMaintenance.Godot.Screens; @@ -77,24 +78,27 @@ public partial class LevelScreen : ScreenBase private Control CreateLayerControls() { var controls = new HBoxContainer(); - controls.AddChild(CreateLayerToggle("Fuel", true, pressed => { + m_FuelLayerToggle = CreateLayerToggle("Fuel", true, pressed => { if (m_GridViewport is null) return; m_GridViewport.ShowFuelLayer = pressed; m_GridViewport.QueueRedraw(); - })); - controls.AddChild(CreateLayerToggle("Water", true, pressed => { + }); + controls.AddChild(m_FuelLayerToggle); + m_WaterLayerToggle = CreateLayerToggle("Water", true, pressed => { if (m_GridViewport is null) return; m_GridViewport.ShowWaterLayer = pressed; m_GridViewport.QueueRedraw(); - })); - controls.AddChild(CreateLayerToggle("Electricity", true, pressed => { + }); + controls.AddChild(m_WaterLayerToggle); + m_ElectricityLayerToggle = CreateLayerToggle("Electricity", true, pressed => { if (m_GridViewport is null) return; m_GridViewport.ShowElectricityLayer = pressed; m_GridViewport.QueueRedraw(); - })); + }); + controls.AddChild(m_ElectricityLayerToggle); var activeLayer = new OptionButton(); activeLayer.AddItem("Surface", 0); @@ -113,6 +117,7 @@ public partial class LevelScreen : ScreenBase m_GridViewport.QueueRedraw(); }; controls.AddChild(activeLayer); + m_ActiveLayerSelect = activeLayer; return controls; } @@ -132,41 +137,70 @@ public partial class LevelScreen : ScreenBase actions.AddChild(CreateButton("Move", OnMoveAction, "Move robot to adjacent floor cell")); actions.AddChild(CreateButton("Interact", OnInteractAction, "Interact with prop at robot position")); actions.AddChild(CreateButton("Repair", OnRepairAction, "Repair leak at robot position")); + actions.AddChild(CreateButton("Heat Shield", OnHeatShieldAction, "Spend one heat shield for temporary movement safety")); + actions.AddChild(CreateButton("Activate", OnActivateAction, "Activate a ready reactor control at the robot position")); actions.AddChild(CreateButton("Main Menu", () => m_App?.ShowMainMenu())); return actions; } private void OnMoveAction() { - if (m_Session is null || m_EditMode) return; + if (m_Session is null || m_EditMode || m_InputLocked) return; var current = m_Session.RobotPosition; var next = current with { X = current.X + 1 }; if (!m_Session.MoveRobot(next)) { next = current with { Y = current.Y + 1 }; - m_Session.MoveRobot(next); + if (!m_Session.MoveRobot(next)) + SetEditorStatus("No quick move target to the east or south."); } } private void OnInteractAction() { - if (!m_EditMode) - m_Session?.InteractProp(); + if (!m_EditMode && !m_InputLocked) + RunAction(() => m_Session?.InteractProp() ?? false); } private void OnRepairAction() { - if (m_Session is null || m_EditMode) return; + if (m_Session is null || m_EditMode || m_InputLocked) return; foreach (var leak in m_Session.Leaks) { if (leak.AccessPosition == m_Session.RobotPosition && !leak.Repaired) { - m_Session.InteractLeak(leak.Carrier, true); + RunAction(() => m_Session.InteractLeak(leak.Carrier, true)); return; } } + + SetEditorStatus("No reachable leak with available remedy."); + } + + private void OnHeatShieldAction() + { + if (m_Session is null || m_EditMode || m_InputLocked) + return; + + RunAction(m_Session.ApplyHeatShield); + } + + private void OnActivateAction() + { + if (m_Session is null || m_EditMode || m_InputLocked) + return; + + var before = m_Session.LevelState.Global.LevelState; + if (!m_Session.ActivateReactor() || before == m_Session.LevelState.Global.LevelState) + SetEditorStatus("Reactor is not ready at this position."); + } + + private void RunAction(Func action) + { + if (!action()) + SetEditorStatus(m_Session?.LevelState.Global.Status ?? "Action unavailable."); } private Control CreateEditorControls() @@ -235,10 +269,14 @@ public partial class LevelScreen : ScreenBase var ls = m_Session.LevelState; m_Header?.SetLevel(m_Level?.Name ?? ls.Name, m_LevelNumber, m_LevelCount, ls.Global.LevelState); + UpdateTerminalGating(ls); m_GridViewport?.SetLevelState(ls); UpdateInspector(m_GridViewport?.SelectedCell ?? new Vector2I(ls.Robot.Position.X, ls.Robot.Position.Y)); - m_ForecastList.SetForecasts(ls.Forecasts); + if (ls.HasActivePoweredTerminalAccess()) + m_ForecastList.SetForecasts(ls.Forecasts); + else + m_ForecastList.SetUnavailable("Terminal access required."); m_InventoryStrip.SetInventory( m_Session.LevelState.Robot.FuelNeutralizers, @@ -256,21 +294,11 @@ public partial class LevelScreen : ScreenBase var position = new GridPosition(cell.X, cell.Y); if (!ls.InBounds(position)) { - m_Inspector.SetCellInfo(null, ECellTerrain.Wall, EPropType.None, EConsumerServiceState.Unknown, 0, 0, 0, 0); + m_Inspector.SetCellInfo(null, null, false); return; } - var surface = ls.GetSurface(position); - var prop = ls.GetProp(position); - m_Inspector.SetCellInfo( - position, - ls.GetTerrain(position), - prop.Type, - prop.ServiceState, - surface.Fuel, - surface.Water, - surface.Electricity, - surface.Heat); + m_Inspector.SetCellInfo(ls, position, ls.HasActivePoweredTerminalAccess()); } private void OnLevelStateChanged(GameSession sender) @@ -285,7 +313,7 @@ public partial class LevelScreen : ScreenBase private void OnPulseAdvanced(GameSession sender) { - UpdateUI(); + BeginPulsePlayback(sender.LastPulseSteps); if (sender.LevelState.Global.LevelState is ELevelState.Lost or ELevelState.Won) { ShowOutcomeOverlay(sender.LevelState.Global.LevelState); @@ -332,7 +360,7 @@ public partial class LevelScreen : ScreenBase private void OnGridClicked(Vector2I cell) { - if (m_Session is null) + if (m_Session is null || m_InputLocked) return; var destination = new GridPosition(cell.X, cell.Y); @@ -347,6 +375,78 @@ public partial class LevelScreen : ScreenBase m_Session.MoveRobot(destination); } + private void UpdateTerminalGating(LevelState level) + { + var canSeeUnderground = level.HasActivePoweredTerminalAccess(); + foreach (var toggle in new[] { m_FuelLayerToggle, m_WaterLayerToggle, m_ElectricityLayerToggle }) + { + if (toggle is null) + continue; + + toggle.Disabled = !canSeeUnderground; + toggle.TooltipText = canSeeUnderground ? "Toggle terminal-visible underground layer" : "Use a powered All-Seeing-Eye terminal to view underground layers"; + } + + if (m_ActiveLayerSelect is not null) + { + m_ActiveLayerSelect.Disabled = !canSeeUnderground; + if (!canSeeUnderground) + { + m_ActiveLayerSelect.Select(0); + if (m_GridViewport is not null) + m_GridViewport.ActiveUndergroundLayer = null; + } + } + + if (m_GridViewport is null) + return; + + m_GridViewport.ShowFuelLayer = canSeeUnderground && (m_FuelLayerToggle?.ButtonPressed ?? false); + m_GridViewport.ShowWaterLayer = canSeeUnderground && (m_WaterLayerToggle?.ButtonPressed ?? false); + m_GridViewport.ShowElectricityLayer = canSeeUnderground && (m_ElectricityLayerToggle?.ButtonPressed ?? false); + } + + private void BeginPulsePlayback(IReadOnlyList steps) + { + if (steps.Count == 0) + { + UpdateUI(); + return; + } + + m_InputLocked = true; + SetEditorStatus("Pulse resolving..."); + var index = 0; + var timer = new Timer { + OneShot = false, + WaitTime = 0.12 + }; + timer.Timeout += () => { + var snapshot = steps[Math.Min(index, steps.Count - 1)]; + ShowPlaybackSnapshot(snapshot); + index++; + if (index < steps.Count) + return; + + timer.Stop(); + UpdateUI(); + m_InputLocked = false; + SetEditorStatus(m_EditMode ? CurrentValidationText() : $"Pulse {m_Session?.LevelState.Global.Pulse ?? 0}"); + timer.QueueFree(); + }; + AddChild(timer); + timer.Start(); + } + + private void ShowPlaybackSnapshot(LevelState snapshot) + { + m_GridViewport?.SetLevelState(snapshot); + var cell = m_GridViewport?.SelectedCell ?? new Vector2I(snapshot.Robot.Position.X, snapshot.Robot.Position.Y); + var position = new GridPosition(cell.X, cell.Y); + m_Inspector?.SetCellInfo(snapshot, snapshot.InBounds(position) ? position : snapshot.Robot.Position, snapshot.HasActivePoweredTerminalAccess()); + SetEditorStatus($"Pulse {snapshot.Global.Pulse + 1} Step {snapshot.Global.Step}"); + } + private EditorToolCommand CurrentEditorCommand() { return new() { @@ -390,13 +490,18 @@ public partial class LevelScreen : ScreenBase }; } + private OptionButton? m_ActiveLayerSelect; + private AppController? m_App; private OptionButton? m_CarrierSelect; private bool m_EditMode; private Label? m_EditorStatus; + private CheckBox? m_ElectricityLayerToggle; private ForecastList? m_ForecastList; + private CheckBox? m_FuelLayerToggle; private GridViewport? m_GridViewport; private LevelHeader? m_Header; + private bool m_InputLocked; private CellInspector? m_Inspector; private InventoryStrip? m_InventoryStrip; private CampaignLevel? m_Level; @@ -407,4 +512,5 @@ public partial class LevelScreen : ScreenBase private OptionButton? m_RemedySelect; private GameSession? m_Session; private OptionButton? m_ToolSelect; + private CheckBox? m_WaterLayerToggle; } \ No newline at end of file diff --git a/src/ReactorMaintenance.Simulation/SimulationEngine.cs b/src/ReactorMaintenance.Simulation/SimulationEngine.cs index ec8c9d5..0874f9d 100644 --- a/src/ReactorMaintenance.Simulation/SimulationEngine.cs +++ b/src/ReactorMaintenance.Simulation/SimulationEngine.cs @@ -2,6 +2,8 @@ public sealed class SimulationEngine { + public sealed record PulseTrace(LevelState FinalState, IReadOnlyList Steps); + public LevelState MoveRobot(LevelState level, GridPosition destination) { return PlayerActionSystem.MoveRobot(level, destination); @@ -12,16 +14,55 @@ public sealed class SimulationEngine return PlayerActionSystem.InteractProp(level, ResolvePulse); } + public PulseTrace InteractPropWithPulseTrace(LevelState level) + { + PulseTrace? trace = null; + var finalState = PlayerActionSystem.InteractProp(level, Resolve); + return trace ?? new(finalState, Array.Empty()); + + LevelState Resolve(LevelState accepted) + { + trace = ResolvePulseWithTrace(accepted); + return trace.FinalState; + } + } + public LevelState InteractLeak(LevelState level, ECarrierType carrier, bool useRemedy) { return PlayerActionSystem.InteractLeak(level, carrier, useRemedy, ResolvePulse); } + public PulseTrace InteractLeakWithPulseTrace(LevelState level, ECarrierType carrier, bool useRemedy) + { + PulseTrace? trace = null; + var finalState = PlayerActionSystem.InteractLeak(level, carrier, useRemedy, Resolve); + return trace ?? new(finalState, Array.Empty()); + + LevelState Resolve(LevelState accepted) + { + trace = ResolvePulseWithTrace(accepted); + return trace.FinalState; + } + } + public LevelState ApplyHeatShield(LevelState level) { return PlayerActionSystem.ApplyHeatShield(level, ResolvePulse); } + public PulseTrace ApplyHeatShieldWithPulseTrace(LevelState level) + { + PulseTrace? trace = null; + var finalState = PlayerActionSystem.ApplyHeatShield(level, Resolve); + return trace ?? new(finalState, Array.Empty()); + + LevelState Resolve(LevelState accepted) + { + trace = ResolvePulseWithTrace(accepted); + return trace.FinalState; + } + } + public LevelState ActivateReactor(LevelState level) { return ReactorSystem.Activate(level); @@ -46,13 +87,22 @@ public sealed class SimulationEngine } private LevelState ResolvePulse(LevelState level) + { + return ResolvePulseWithTrace(level).FinalState; + } + + private PulseTrace ResolvePulseWithTrace(LevelState level) { var next = ValidateAndPropagate(level); if (next.Global.LevelState == ELevelState.Lost) - return next; + return new(next, [next]); + var steps = new List(); for (var i = 0; i < Balancing.Current.StepsPerPulse; i++) + { next = ResolveStepContent(next); + steps.Add(next); + } next = ReactorSystem.DeriveState(next); next = SurfaceInteractionSystem.AdvanceDurations(next); @@ -63,7 +113,9 @@ public sealed class SimulationEngine } }; - return next with { Forecasts = Forecast(next) }; + next = next with { Forecasts = Forecast(next) }; + steps.Add(next); + return new(next, steps); } private LevelState ResolveStep(LevelState level, bool refreshForecasts = true)