From f21810b61690aa99ad52076d13683adffa68948b Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Mon, 10 Aug 2026 00:09:58 +0200 Subject: [PATCH] fix(gameplay): surface blockers and clear attack targets --- .../Gameplay/CommandCardPresenterTests.cs | 70 +++++++++ .../Simulation/KernelIntegrationTests.cs | 29 ++++ .../Gameplay/UI/CommandCardPresenter.cs | 68 +++++++- .../Scripts/Presentation/UI/BuildMenuHud.cs | 146 ++++++++++++------ .../Scripts/Presentation/UI/CommandCardHud.cs | 15 +- .../Simulation/State/UnitCommandStateView.cs | 5 +- CHANGELOG.md | 1 + .../KernelIntegrationTests.cs | 29 ++++ 8 files changed, 313 insertions(+), 50 deletions(-) diff --git a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs index c23fcf1..42d97f1 100644 --- a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs +++ b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs @@ -1,6 +1,7 @@ using NUnit.Framework; using Nova.Core; using Nova.Gameplay; +using Nova.Simulation.Construction; using Nova.Simulation.Definitions; using Nova.Simulation.Production; using Nova.Simulation.State; @@ -239,6 +240,75 @@ public void EvaluateProductionBlocker_InsufficientCreditsAndNone() "unlocked, funded and queue space: the executor would apply"); } + [Test] + public void EvaluateBuildingPlacementBlocker_FollowsExecutorOrder() + { + Assert.IsTrue(SimDefinitions.TryGetBuilding( + FactionId.Alliance, UnitRole.VehicleFactory, out SimBuildingDefinition factory)); + + Assert.AreEqual( + BuildingPlacementBlocker.MissingPrerequisite, + CommandCardPresenter.EvaluateBuildingPlacementBlocker( + in factory, prerequisiteMet: false, credits: 0, + powerProvided: 0, powerRequired: 0, activeSiteCount: ConstructionSystem.MaxSites), + "the prerequisite wins when every blocker applies"); + Assert.AreEqual( + BuildingPlacementBlocker.InsufficientCredits, + CommandCardPresenter.EvaluateBuildingPlacementBlocker( + in factory, prerequisiteMet: true, credits: factory.CostAE - 1, + powerProvided: 0, powerRequired: 0, activeSiteCount: ConstructionSystem.MaxSites), + "affordability wins over power and capacity"); + Assert.AreEqual( + BuildingPlacementBlocker.InsufficientPower, + CommandCardPresenter.EvaluateBuildingPlacementBlocker( + in factory, prerequisiteMet: true, credits: factory.CostAE, + powerProvided: factory.PowerRequired - 1, powerRequired: 0, + activeSiteCount: ConstructionSystem.MaxSites), + "power wins over site capacity"); + Assert.AreEqual( + BuildingPlacementBlocker.SiteCapacityReached, + CommandCardPresenter.EvaluateBuildingPlacementBlocker( + in factory, prerequisiteMet: true, credits: factory.CostAE, + powerProvided: factory.PowerRequired, powerRequired: 0, + activeSiteCount: ConstructionSystem.MaxSites), + "free power equal to the draw is sufficient, exposing the later capacity blocker"); + Assert.AreEqual( + BuildingPlacementBlocker.None, + CommandCardPresenter.EvaluateBuildingPlacementBlocker( + in factory, prerequisiteMet: true, credits: factory.CostAE, + powerProvided: factory.PowerRequired, powerRequired: 0, + activeSiteCount: ConstructionSystem.MaxSites - 1)); + } + + [Test] + public void EvaluateBuildingPlacementBlocker_ZeroDrawNeverEnergyBlocks() + { + Assert.IsTrue(SimDefinitions.TryGetBuilding( + FactionId.Alliance, UnitRole.Power, out SimBuildingDefinition powerPlant)); + + Assert.AreEqual( + BuildingPlacementBlocker.None, + CommandCardPresenter.EvaluateBuildingPlacementBlocker( + in powerPlant, prerequisiteMet: true, credits: powerPlant.CostAE, + powerProvided: 0, powerRequired: 100, activeSiteCount: 0)); + } + + [Test] + public void PowerFormatters_NameBalanceConsequenceAndBuildingDraw() + { + Assert.AreEqual("Strom 30/20", CommandCardPresenter.FormatPowerBalance(30, 20)); + Assert.AreEqual( + "Strom 20/40 · LOW POWER: Produktion ½", + CommandCardPresenter.FormatPowerBalance(20, 40)); + + Assert.IsTrue(SimDefinitions.TryGetBuilding( + FactionId.Alliance, UnitRole.Power, out SimBuildingDefinition powerPlant)); + Assert.IsTrue(SimDefinitions.TryGetBuilding( + FactionId.Alliance, UnitRole.Refinery, out SimBuildingDefinition refinery)); + Assert.AreEqual("Erzeugt +100 Strom", CommandCardPresenter.FormatBuildingPower(in powerPlant)); + Assert.AreEqual("Benötigt 20 Strom", CommandCardPresenter.FormatBuildingPower(in refinery)); + } + [Test] public void EvaluateBuildingRepairBlocker_FollowsTheSimRepairRule() { diff --git a/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs index 45ee821..f8c8005 100644 --- a/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs @@ -153,6 +153,35 @@ public void SealedMoveCommand_ChangesUnitStateAtTargetTick() "the ordered unit must actually move after the target tick"); } + [Test] + public void SealedStopCommand_ClearsMovementAndAttackTarget_WithoutCombatSystem() + { + var host = TestHost.Create(Seed); + EntityId unit = host.Entities.SpawnUnit( + 0, new Transform2D(SimFixed.FromFloat(10.5f), SimFixed.FromFloat(10.5f)), SimFixed.FromInt(5)); + EntityId target = host.Entities.SpawnUnit( + 1, new Transform2D(SimFixed.FromFloat(20.5f), SimFixed.FromFloat(20.5f)), SimFixed.FromInt(5)); + + ref UnitState state = ref host.Entities.GetUnitRef(unit); + state.SetTarget(new GridPos2D(30, 30)); + state.AttackTarget = target; + + var stop = new StopPayload(new[] { UnitCommandStateView.ToRawEntityId(unit) }); + Assert.AreEqual( + CommandIngressResult.Accepted, + host.Ingress.TrySubmitIntent(CommandIntent.Create(stop), out _)); + + host.StepTick(); + + Assert.AreEqual(1, host.Kernel.LastTickResults.Count); + Assert.AreEqual(CommandResultCode.Applied, host.Kernel.LastTickResults[0].Code); + ref readonly UnitState stopped = ref host.Entities.GetUnitRef(unit); + Assert.IsFalse(stopped.IsMoving); + Assert.IsFalse(stopped.TargetGridPos.IsValid); + Assert.IsFalse(stopped.GoalGridPos.IsValid); + Assert.AreEqual(EntityId.Invalid, stopped.AttackTarget); + } + [Test] public void StateHash_ReflectsStateMutation_AndStaysStableOnRepeat() { diff --git a/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs b/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs index 2885b47..1f28cfb 100644 --- a/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs +++ b/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs @@ -1,5 +1,6 @@ using System; using Nova.Core; +using Nova.Simulation.Construction; using Nova.Simulation.Definitions; using Nova.Simulation.Production; using Nova.Simulation.State; @@ -10,7 +11,8 @@ namespace Nova.Gameplay /// The buttons one command card can show. Presence is role-driven /// (); whether a present button is /// clickable is a separate, state-driven evaluation - /// (, ) + /// (, , + /// ) /// — a greyed button must always carry its reason. /// [Flags] @@ -44,6 +46,22 @@ public enum ProductionBlocker InsufficientCredits = 3, } + /// + /// Why a building cannot be placed, in the executor's state-dependent + /// validation order after target geometry: prerequisite, affordability, + /// free power and finally the global construction-site capacity. The UI + /// derives this reason because schema v1 deliberately shares one result + /// code between several of these cases. + /// + public enum BuildingPlacementBlocker + { + None = 0, + MissingPrerequisite = 1, + InsufficientCredits = 2, + InsufficientPower = 3, + SiteCapacityReached = 4, + } + /// /// Why a building's repair button is blocked. The sim's repair order /// needs a live Builder as the actor and an own COMPLETED placement that @@ -202,6 +220,54 @@ public static ProductionBlocker EvaluateProductionBlocker( return ProductionBlocker.None; } + /// + /// First building-placement blocker in the executor's own order. + /// Geometry is intentionally absent: the build bar has no target cell + /// until placement mode starts. An energy blocker is informational in + /// that bar and must not disable entering placement mode. + /// + public static BuildingPlacementBlocker EvaluateBuildingPlacementBlocker( + in SimBuildingDefinition definition, bool prerequisiteMet, long credits, + int powerProvided, int powerRequired, int activeSiteCount) + { + if (!prerequisiteMet) return BuildingPlacementBlocker.MissingPrerequisite; + if (credits < definition.CostAE) return BuildingPlacementBlocker.InsufficientCredits; + if (definition.PowerRequired > 0 + && powerProvided - powerRequired < definition.PowerRequired) + { + return BuildingPlacementBlocker.InsufficientPower; + } + if (activeSiteCount >= ConstructionSystem.MaxSites) + { + return BuildingPlacementBlocker.SiteCapacityReached; + } + return BuildingPlacementBlocker.None; + } + + /// + /// Compact live grid balance for the build bar. Low power must name + /// its gameplay consequence where the player makes build decisions. + /// + public static string FormatPowerBalance(int powerProvided, int powerRequired) + { + string balance = $"Strom {powerProvided}/{powerRequired}"; + return powerRequired > powerProvided + ? balance + " · LOW POWER: Produktion ½" + : balance; + } + + /// Power generation or draw shown on a selected building's command card and on build-button hover. + public static string FormatBuildingPower(in SimBuildingDefinition definition) + { + if (definition.PowerProvided > 0 && definition.PowerRequired > 0) + { + return $"Erzeugt +{definition.PowerProvided} · benötigt {definition.PowerRequired} Strom"; + } + if (definition.PowerProvided > 0) return $"Erzeugt +{definition.PowerProvided} Strom"; + if (definition.PowerRequired > 0) return $"Benötigt {definition.PowerRequired} Strom"; + return "Kein Strombedarf"; + } + /// /// First blocker of a building's repair button: an undamaged building /// has nothing to repair (the executor rejects such an order as an diff --git a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs index d1163e6..bc042ef 100644 --- a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs @@ -26,8 +26,9 @@ namespace Nova.Presentation.UI /// the bar, where it appears while the entry is hovered. /// /// - /// THE STATUS LINE above the bar serves three masters in priority order: - /// the hovered entry's blocker reason, then the D-085 builder warning + /// THE STATUS LINE above the bar always carries the live power balance on + /// the left. Its right side serves three masters in priority order: the + /// hovered entry's blocker or power value, then the D-085 builder warning /// ("Kein Builder — Bau pausiert…") shown as long as any own construction /// site has no living Builder — the visible warning instead of the silent /// dead end —, then the onboarding hint below. @@ -38,9 +39,9 @@ namespace Nova.Presentation.UI /// alternative BuildingRegistrySO has no asset instances in the project /// and is not wired at runtime, so SimDefinitions is the honest source: /// the bar cannot drift from the executor. Availability mirrors the - /// executor's own rule precisely — the prerequisite check is the sim's - /// and the credit - /// check is the balance the executor charges at placement. + /// prerequisite, credit and global site-capacity gates. Power is derived + /// and shown too, but deliberately does not disable entering placement + /// mode — the target-cell decision still belongs to placement. /// /// /// Clicking an available entry calls @@ -82,9 +83,6 @@ public sealed class BuildMenuHud : MonoBehaviour /// Horizontal gap between two buttons (explicit rects, so the gap is exactly this, not a GUILayout margin guess). private const float ButtonSpacing = 4f; - /// Width of the status line above the bar (centered on the zone), same measure the onboarding hint had. - private const float StatusLineWidth = 640f; - [Header("Wiring (scene generator)")] [SerializeField] private MatchRunner _runner; [SerializeField] private RtsDeviceInput _input; @@ -106,6 +104,7 @@ public sealed class BuildMenuHud : MonoBehaviour private readonly StringBuilder _builder = new StringBuilder(64); private GUIStyle _buttonStyle; private GUIStyle _statusStyle; + private GUIStyle _powerStatusStyle; private bool _hintDismissed; private float _hintShownAt = -1f; @@ -113,8 +112,7 @@ public sealed class BuildMenuHud : MonoBehaviour // the status line and the buttons. private bool _siteLacksBuilder; private int _siteLacksBuilderFrame = -1; - private string _hoveredBlockerReason; - private string _hoveredRadarHint; + private string _hoveredStatusText; private string _transientNotice; private float _transientNoticeUntil; @@ -273,7 +271,9 @@ private void DrawBar() if (economy == null || construction == null) return; // match not initialized yet byte slot = _runner.Session != null ? _runner.Session.LocalSlot : (byte)0; - long credits = economy.GetPlayerEconomy(slot).AetheriumCredits; + ref readonly PlayerEconomyState playerEconomy = ref economy.GetPlayerEconomy(slot); + long credits = playerEconomy.AetheriumCredits; + int activeSiteCount = construction.SiteCount; FactionId faction = economy.GetSlotFaction(slot); Rect zone = ComputeBarZone(); @@ -287,8 +287,7 @@ private void DrawBar() // is exactly why they are explicit. float scale = Mathf.Max(1f, _uiScale); Vector2 guiMouse = HudLayout.RawMouseToGui(Input.mousePosition, scale); - _hoveredBlockerReason = null; - _hoveredRadarHint = null; + _hoveredStatusText = null; for (int i = 0; i < BuildableRoles.Length; i++) { UnitRole role = BuildableRoles[i]; @@ -298,29 +297,40 @@ private void DrawBar() buttonsRect.x + i * (buttonWidth + ButtonSpacing), buttonsRect.y, buttonWidth, _buttonHeight); if (!rect.Contains(guiMouse)) continue; - if (!IsAvailable(in def, slot, credits, construction)) - { - _hoveredBlockerReason = BlockerReason(role, in def, slot, credits, construction); - } - else if (role == UnitRole.Radar) + bool prerequisiteMet = PrerequisiteMet(in def, slot, construction); + BuildingPlacementBlocker blocker = CommandCardPresenter.EvaluateBuildingPlacementBlocker( + in def, prerequisiteMet, credits, + playerEconomy.PowerProvided, playerEconomy.PowerRequired, activeSiteCount); + _hoveredStatusText = BlockerReason( + role, in def, blocker, credits, + playerEconomy.PowerProvided, playerEconomy.PowerRequired, activeSiteCount); + if (_hoveredStatusText == null) { - // 16.5 (#54, C3): the button says in plain text what it - // unlocks — the minimap is a Radar function now, and - // losing the building takes the map away again. - _hoveredRadarHint = "Radar: schaltet die Minimap frei — ohne Radar keine Karte"; + _hoveredStatusText = $"{CommandCardPresenter.BuildingDisplayName(role)}: " + + CommandCardPresenter.FormatBuildingPower(in def); + if (role == UnitRole.Radar) + { + // 16.5 (#54, C3): keep the minimap unlock explicit + // while 16.10 adds the building's power draw. + _hoveredStatusText += " · schaltet Minimap frei"; + } } } // Chrome and status line paint BEFORE the buttons — IMGUI paints // in call order and later calls sit on top. - DrawChromeAndStatusLine(zone, buttonsRect); + DrawChromeAndStatusLine( + zone, buttonsRect, + CommandCardPresenter.FormatPowerBalance( + playerEconomy.PowerProvided, playerEconomy.PowerRequired)); for (int i = 0; i < BuildableRoles.Length; i++) { UnitRole role = BuildableRoles[i]; if (!SimDefinitions.TryGetBuilding(faction, role, out SimBuildingDefinition def)) continue; - bool available = IsAvailable(in def, slot, credits, construction); + bool prerequisiteMet = PrerequisiteMet(in def, slot, construction); + bool available = IsAvailable(in def, prerequisiteMet, credits, activeSiteCount); var rect = new Rect( buttonsRect.x + i * (buttonWidth + ButtonSpacing), buttonsRect.y, buttonWidth, _buttonHeight); @@ -335,30 +345,51 @@ private void DrawBar() } } - /// Entry availability, the executor's own rule: prerequisite finished (if any) and enough credits. - private static bool IsAvailable(in SimBuildingDefinition def, byte slot, long credits, ConstructionSystem construction) + private static bool PrerequisiteMet( + in SimBuildingDefinition def, byte slot, ConstructionSystem construction) { - bool prerequisiteMet = !def.HasPrerequisite + return !def.HasPrerequisite || construction.HasFinishedBuilding(slot, def.PrerequisiteRole); - return prerequisiteMet && credits >= def.CostAE; + } + + /// + /// Whether the button may enter placement mode. Energy is + /// deliberately absent: its blocker is visible on hover, but the + /// player must still be able to inspect terrain in placement mode. + /// + private static bool IsAvailable( + in SimBuildingDefinition def, bool prerequisiteMet, long credits, int activeSiteCount) + { + return prerequisiteMet + && credits >= def.CostAE + && activeSiteCount < ConstructionSystem.MaxSites; } /// /// The chrome frame and the status line. IMGUI paints in call order, /// so this runs BEFORE the buttons and the box stays behind them. /// - private void DrawChromeAndStatusLine(Rect zone, Rect buttonsRect) + private void DrawChromeAndStatusLine(Rect zone, Rect buttonsRect, string powerBalance) { GUI.Box( new Rect(buttonsRect.x - 4f, buttonsRect.y - 4f, buttonsRect.width + 8f, buttonsRect.height + 8f), GUIContent.none, HudChrome.PanelStyle); - string statusText = ResolveStatusLineText(); - if (statusText == null) return; - - var rect = new Rect(zone.center.x - StatusLineWidth * 0.5f, zone.y, StatusLineWidth, StatusLineHeight); + var rect = new Rect(buttonsRect.x, zone.y, buttonsRect.width, StatusLineHeight); GUI.Box(rect, GUIContent.none, HudChrome.PanelStyle); - GUI.Label(rect, statusText, _statusStyle); + + float powerWidth = Mathf.Min(260f, rect.width * 0.36f); + GUI.Label( + new Rect(rect.x + 8f, rect.y, powerWidth - 8f, rect.height), + powerBalance, _powerStatusStyle); + + string statusText = ResolveStatusLineText(); + if (statusText != null) + { + GUI.Label( + new Rect(rect.x + powerWidth, rect.y, rect.width - powerWidth - 8f, rect.height), + statusText, _statusStyle); + } } /// @@ -366,12 +397,12 @@ private void DrawChromeAndStatusLine(Rect zone, Rect buttonsRect) /// blocker reason (the player is interrogating that button right /// now), then the D-085 builder warning while any own site lacks a /// Builder, then the onboarding hint until it dismisses itself. - /// Null = the line stays empty (and unpainted). + /// Null leaves the contextual right side empty; the power balance on + /// the left remains visible. /// private string ResolveStatusLineText() { - if (_hoveredBlockerReason != null) return _hoveredBlockerReason; - if (_hoveredRadarHint != null) return _hoveredRadarHint; + if (_hoveredStatusText != null) return _hoveredStatusText; if (_transientNotice != null && Time.unscaledTime < _transientNoticeUntil) return _transientNotice; if (_siteLacksBuilder) return ConstructionSiteStatus.NoBuilderWarning; if (!_hintDismissed && _runner.IsRunning) return HintText; @@ -417,17 +448,30 @@ private string ButtonLabel(UnitRole role, in SimBuildingDefinition def, float bu return nameLine + "\n" + costLine; } - /// The hovered entry's blocker, in the executor's own check order — prerequisite first, then affordability. + /// The hovered entry's first blocker, derived in the executor's own check order. private static string BlockerReason( - UnitRole role, in SimBuildingDefinition def, byte slot, long credits, ConstructionSystem construction) + UnitRole role, in SimBuildingDefinition def, BuildingPlacementBlocker blocker, + long credits, int powerProvided, int powerRequired, int activeSiteCount) { - bool prerequisiteMet = !def.HasPrerequisite - || construction.HasFinishedBuilding(slot, def.PrerequisiteRole); - if (!prerequisiteMet) + string name = CommandCardPresenter.BuildingDisplayName(role); + string buildingPower = CommandCardPresenter.FormatBuildingPower(in def); + switch (blocker) { - return $"{CommandCardPresenter.BuildingDisplayName(role)}: benötigt {CommandCardPresenter.BuildingDisplayName(def.PrerequisiteRole)}"; + case BuildingPlacementBlocker.MissingPrerequisite: + return $"{name}: benötigt {CommandCardPresenter.BuildingDisplayName(def.PrerequisiteRole)}" + + $" · {buildingPower}"; + case BuildingPlacementBlocker.InsufficientCredits: + return $"{name}: nicht genug Aetherium ({credits}/{def.CostAE} AE)" + + $" · {buildingPower}"; + case BuildingPlacementBlocker.InsufficientPower: + return $"{name}: benötigt {def.PowerRequired} Strom · " + + $"{Mathf.Max(0, powerProvided - powerRequired)} frei"; + case BuildingPlacementBlocker.SiteCapacityReached: + return $"{name}: Baustellenlimit erreicht ({activeSiteCount}/{ConstructionSystem.MaxSites})" + + $" · {buildingPower}"; + default: + return null; } - return $"{CommandCardPresenter.BuildingDisplayName(role)}: nicht genug Aetherium"; } /// @@ -481,9 +525,19 @@ private void EnsureStyles() { _statusStyle = new GUIStyle(GUI.skin.label) { - fontSize = 13, + fontSize = 9, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleRight, + wordWrap = false + }; + } + if (_powerStatusStyle == null) + { + _powerStatusStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 10, fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleCenter, + alignment = TextAnchor.MiddleLeft, wordWrap = false }; } diff --git a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs index 2b8bec7..1b92529 100644 --- a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs @@ -28,7 +28,8 @@ namespace Nova.Presentation.UI /// or when no Builder exists), plus — for producers — one production /// button per unit the building builds (from /// filtered by faction and producer - /// role, the table the executor validates against), greyed WITH the + /// role, the table the executor validates against), plus the selected + /// building's power draw or generation from the same definition, greyed WITH the /// reason (benötigt Forschungslabor / Warteschlange voll / nicht genug /// Aetherium — the executor's own validation order, so the reason the /// player reads is the one the executor would reject with). The queue is @@ -114,6 +115,8 @@ private sealed class CardModel public bool Visible; public string Title = string.Empty; public EntityId LeadId; + /// Generation or draw of a completed building; null on unit and site cards. + public string BuildingPowerText; public readonly List Buttons = new List(16); public string QueueHeader; public readonly List QueueRows = new List(ProductionSystem.MaxQueueEntries); @@ -130,6 +133,7 @@ public void Clear() Visible = false; Title = string.Empty; LeadId = EntityId.Invalid; + BuildingPowerText = null; Buttons.Clear(); QueueHeader = null; QueueRows.Clear(); @@ -274,6 +278,10 @@ private void BuildBuildingModel( CommandButtonType commands = _presenter.GetBuildingCommands(building.Role); bool definitionKnown = SimDefinitions.TryGetBuilding(faction, building.Role, out SimBuildingDefinition def); + if (definitionKnown) + { + model.BuildingPowerText = CommandCardPresenter.FormatBuildingPower(in def); + } if (commands.HasFlag(CommandButtonType.Sell)) { @@ -481,6 +489,10 @@ private void OnGUI() GUILayout.BeginVertical(HudChrome.PanelStyle); GUILayout.Label(model.Title, _titleStyle, GUILayout.Height(TitleHeight)); + if (model.BuildingPowerText != null) + { + GUILayout.Label(model.BuildingPowerText, _rowStyle, GUILayout.Height(RowHeight)); + } if (model.ProgressBar01 >= 0f) DrawProgressBar(model.ProgressBar01); if (model.SiteStatusText != null) { @@ -581,6 +593,7 @@ private float EstimateHeight(CardModel model) { float height = HudChrome.PanelStyle.padding.vertical; height += TitleHeight + _titleStyle.margin.vertical; + if (model.BuildingPowerText != null) height += RowHeight + _rowStyle.margin.vertical; if (model.ProgressBar01 >= 0f) height += ProgressHeight; // GUIStyle.none: no margin if (model.SiteStatusText != null) height += SiteStatusHeight + _siteStatusStyle.margin.vertical; for (int i = 0; i < model.Buttons.Count; i++) diff --git a/Assets/_Project/Scripts/Simulation/State/UnitCommandStateView.cs b/Assets/_Project/Scripts/Simulation/State/UnitCommandStateView.cs index b42fbb3..b5cc998 100644 --- a/Assets/_Project/Scripts/Simulation/State/UnitCommandStateView.cs +++ b/Assets/_Project/Scripts/Simulation/State/UnitCommandStateView.cs @@ -275,8 +275,9 @@ public void Apply(in CommandRecord record) { ref UnitState unit = ref _entityManager.GetUnitRef(id); unit.Stop(); - // Stop cancels every standing order, economy and - // repair orders included; the unit keeps its cargo. + unit.AttackTarget = EntityId.Invalid; + // Stop cancels every standing order: attack, + // economy and repair included; the unit keeps its cargo. unit.HarvestFieldId = 0; unit.IsReturningCargo = false; _constructionSystem?.ClearRepairOrder(stop.EntityIds[i]); diff --git a/CHANGELOG.md b/CHANGELOG.md index f149aaf..768cb46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de sie belegt keine Verbesserung. ### Behoben +- **#45/#47/#48: Entscheidungspunkt und „Stoppen“ melden jetzt die Wahrheit (D-097):** Die Baubar zeigt dauerhaft die Strombilanz samt Low-Power-Folge, nennt beim Überfahren Bedarf beziehungsweise Erzeugung und leitet den ersten Blocker in Simulationsreihenfolge aus Voraussetzung, AE, freier Energie und Baustellenlimit her; Energie sperrt den Eintritt in den Platzierungsmodus bewusst nicht. Die Befehlskarte zeigt den Stromwert des gewählten Gebäudes, und ein angewandter Stop-Befehl räumt zusätzlich `AttackTarget` ab. Ein echtes Halte-Feuer bleibt ausserhalb dieses Pakets, weil D-087 im nächsten Combat-Tick wieder ein Ziel erfassen darf - **#54: Das Radar wird ein Gebäude (C3/D-096)** — die Minimap ist jetzt eine Radar-Funktion: `MinimapHud` zeichnet (Panel und Trefferfläche) nur noch, solange der lokale Slot ein fertiges Radar besitzt; der Bauknopf sagt es im diff --git a/tools/Nova.SimRunner.Tests/KernelIntegrationTests.cs b/tools/Nova.SimRunner.Tests/KernelIntegrationTests.cs index a129736..7fa2300 100644 --- a/tools/Nova.SimRunner.Tests/KernelIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/KernelIntegrationTests.cs @@ -153,6 +153,35 @@ public void SealedMoveCommand_ChangesUnitStateAtTargetTick() "the ordered unit must actually move after the target tick"); } + [Test] + public void SealedStopCommand_ClearsMovementAndAttackTarget_WithoutCombatSystem() + { + var host = TestHost.Create(Seed); + EntityId unit = host.Entities.SpawnUnit( + 0, new Transform2D(SimFixed.FromFloat(10.5f), SimFixed.FromFloat(10.5f)), SimFixed.FromInt(5)); + EntityId target = host.Entities.SpawnUnit( + 1, new Transform2D(SimFixed.FromFloat(20.5f), SimFixed.FromFloat(20.5f)), SimFixed.FromInt(5)); + + ref UnitState state = ref host.Entities.GetUnitRef(unit); + state.SetTarget(new GridPos2D(30, 30)); + state.AttackTarget = target; + + var stop = new StopPayload(new[] { UnitCommandStateView.ToRawEntityId(unit) }); + Assert.That( + host.Ingress.TrySubmitIntent(CommandIntent.Create(stop), out _), + Is.EqualTo(CommandIngressResult.Accepted)); + + host.StepTick(); + + Assert.That(host.Kernel.LastTickResults.Count, Is.EqualTo(1)); + Assert.That(host.Kernel.LastTickResults[0].Code, Is.EqualTo(CommandResultCode.Applied)); + ref readonly UnitState stopped = ref host.Entities.GetUnitRef(unit); + Assert.That(stopped.IsMoving, Is.False); + Assert.That(stopped.TargetGridPos.IsValid, Is.False); + Assert.That(stopped.GoalGridPos.IsValid, Is.False); + Assert.That(stopped.AttackTarget, Is.EqualTo(EntityId.Invalid)); + } + [Test] public void StateHash_ReflectsStateMutation_AndStaysStableOnRepeat() {