Complete Godot pulse UX integration

This commit is contained in:
2026-05-14 11:04:36 +02:00
parent 542c0cdc19
commit ad5445b09d
7 changed files with 315 additions and 43 deletions

View File

@@ -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

View File

@@ -23,17 +23,74 @@ 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}");
sb.AppendLine($"Terrain: {terrain}");
sb.AppendLine($"Prop: {prop}");
if (prop != EPropType.None)
sb.AppendLine($"Service: {serviceState}");
sb.AppendLine($"Hazards: {FormatHazards(fuelHazard, waterHazard, electricityHazard, heatHazard)}");
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: {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<string>();
foreach (var carrier in Enum.GetValues<ECarrierType>())
{
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)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<LevelState>();
LevelStateChanged?.Invoke(this);
}
@@ -122,6 +150,7 @@ public sealed class GameSession
public GridPosition RobotPosition => LevelState.Robot.Position;
public GlobalState GlobalState => LevelState.Global;
public IReadOnlyList<Forecast> Forecasts => LevelState.Forecasts;
public IReadOnlyList<LevelState> LastPulseSteps { get; private set; } = Array.Empty<LevelState>();
public IReadOnlyList<LeakState> Leaks => LevelState.Leaks;
public string LevelPath { get; private set; } = string.Empty;
public IReadOnlyList<ReactorState> Reactors => LevelState.Reactors;

View File

@@ -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<bool> 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));
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<LevelState> 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;
}

View File

@@ -2,6 +2,8 @@
public sealed class SimulationEngine
{
public sealed record PulseTrace(LevelState FinalState, IReadOnlyList<LevelState> 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>());
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>());
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>());
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<LevelState>();
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)