From 56bf7e30d2a17945a8ed5d1c4a92fa9febf58faf Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Tue, 18 Aug 2026 23:53:53 +0200 Subject: [PATCH] feat(ui): selection card tells the truth about multi-selects (21.5, #88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command card no longer takes the lead unit's word for a multi-selection: buttons are the intersection over every MOBILE role (GetSharedUnitCommands — building roles are skipped first, their None would wipe the AND), titled "{lead type} — N Einheiten" with per-type breakdown rows "2× Lynx — 180/240 HP" in first-occurrence order. Mixed-in buildings ride along as bystander rows without a command vote, mirroring the input discipline of TryGetLeadProducer; only an all-building selection keeps the building card. A single damaged unit carries its HP in the title, same convention as the building card. EstimateHeight grows row-for-row with OnGUI. Deliberate consequences (the sprint's reading: only commands that hold for everyone): Harvest/ReturnCargo appear only on a pure Harvester selection — which retires the dead Harvest button on mixed selections, the executor rejects it for non-Harvesters anyway — and Repair only on a pure Builder selection. Verified: dotnet test 726/726; Unity EditMode 614/614 (9 new); PlayMode 12/13 (sole failure is the pre-existing NetworkPanel one, red on main too). --- .../Gameplay/CommandCardPresenterTests.cs | 152 ++++++++++++++++++ .../Gameplay/UI/CommandCardPresenter.cs | 115 ++++++++++++- .../Scripts/Presentation/UI/CommandCardHud.cs | 108 +++++++++++-- CHANGELOG.md | 9 ++ 4 files changed, 374 insertions(+), 10 deletions(-) diff --git a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs index 4320fa4..e26567f 100644 --- a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs +++ b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs @@ -407,5 +407,157 @@ public void FormatFieldReserveAE_NonPositiveValuesRenderAsZero() Assert.AreEqual("0 / 0 AE", CommandCardPresenter.FormatFieldReserveAE(0, 0)); Assert.AreEqual("0 / 9.000 AE", CommandCardPresenter.FormatFieldReserveAE(-5, 9000)); } + + // ---------------------------------------------------------------- + // Shared commands over a multi-selection (21.5, #88) + // ---------------------------------------------------------------- + + [Test] + public void GetSharedUnitCommands_PureHarvesters_KeepHarvestAndReturnCargo() + { + var presenter = new CommandCardPresenter(); + + CommandButtonType commands = presenter.GetSharedUnitCommands( + FactionId.Alliance, new[] { UnitRole.Harvester, UnitRole.Harvester }); + + Assert.AreEqual( + CommandButtonType.Move | CommandButtonType.Stop | CommandButtonType.Harvest | CommandButtonType.ReturnCargo, + commands); + } + + [Test] + public void GetSharedUnitCommands_HarvesterPlusBattleTank_KeepsOnlyMoveAndStop() + { + var presenter = new CommandCardPresenter(); + + // The Harvester is unarmed and the BattleTank cannot harvest: + // only the shared core survives the intersection. Previously + // the lead slot alone decided this, so both orders must agree. + CommandButtonType forward = presenter.GetSharedUnitCommands( + FactionId.Alliance, new[] { UnitRole.Harvester, UnitRole.BattleTank }); + CommandButtonType reversed = presenter.GetSharedUnitCommands( + FactionId.Alliance, new[] { UnitRole.BattleTank, UnitRole.Harvester }); + + Assert.AreEqual(CommandButtonType.Move | CommandButtonType.Stop, forward); + Assert.AreEqual(forward, reversed, "the intersection must not depend on the selection order"); + } + + [Test] + public void GetSharedUnitCommands_BuilderPlusHarvester_KeepsOnlyMoveAndStop() + { + var presenter = new CommandCardPresenter(); + + CommandButtonType commands = presenter.GetSharedUnitCommands( + FactionId.Alliance, new[] { UnitRole.Builder, UnitRole.Harvester }); + + Assert.AreEqual(CommandButtonType.Move | CommandButtonType.Stop, commands); + } + + [Test] + public void GetSharedUnitCommands_PureBuilders_KeepRepair() + { + var presenter = new CommandCardPresenter(); + + CommandButtonType commands = presenter.GetSharedUnitCommands( + FactionId.Alliance, new[] { UnitRole.Builder, UnitRole.Builder }); + + Assert.AreEqual(CommandButtonType.Move | CommandButtonType.Stop | CommandButtonType.Repair, commands); + } + + [Test] + public void GetSharedUnitCommands_BuildingRolesAreIgnoredInsteadOfWipingTheIntersection() + { + var presenter = new CommandCardPresenter(); + + // GetUnitCommands returns None for building roles — skipping + // them before intersecting is what keeps a mixed selection's + // buttons alive (buildings ride along as bystanders). + CommandButtonType mixed = presenter.GetSharedUnitCommands( + FactionId.Alliance, new[] { UnitRole.HQ, UnitRole.Harvester, UnitRole.Barracks }); + + Assert.AreEqual( + presenter.GetUnitCommands(FactionId.Alliance, UnitRole.Harvester), + mixed, + "buildings contribute no commands to a mixed selection"); + Assert.AreEqual( + CommandButtonType.None, + presenter.GetSharedUnitCommands(FactionId.Alliance, new[] { UnitRole.HQ }), + "an all-building input has no unit card at all"); + } + + // ---------------------------------------------------------------- + // Selection breakdown (21.5, #88) + // ---------------------------------------------------------------- + + [Test] + public void SummarizeSelection_GroupsByRoleInFirstOccurrenceOrderWithHpSums() + { + var entities = new EntityManager(8); + EntityId leadTank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(3), maxHealth: 240, role: UnitRole.LightTank); + EntityId harvester = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(3), maxHealth: 100, role: UnitRole.Harvester); + EntityId secondTank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(3), SimFixed.FromInt(3)), SimFixed.FromInt(3), maxHealth: 240, role: UnitRole.LightTank); + entities.GetUnitRef(leadTank).CurrentHealth = 120; + entities.GetUnitRef(secondTank).CurrentHealth = 60; + + var presenter = new CommandCardPresenter(); + var groups = new SelectionGroup[4]; + int count = presenter.SummarizeSelection(new[] { leadTank, harvester, secondTank }, entities, groups); + + Assert.AreEqual(2, count); + Assert.AreEqual(UnitRole.LightTank, groups[0].Role, "the lead type stays the top row"); + Assert.AreEqual(2, groups[0].Count); + Assert.AreEqual(180, groups[0].CurrentHealthSum, "summed, not averaged"); + Assert.AreEqual(480, groups[0].MaxHealthSum); + Assert.AreEqual(UnitRole.Harvester, groups[1].Role); + Assert.AreEqual(1, groups[1].Count); + Assert.AreEqual(100, groups[1].CurrentHealthSum); + } + + [Test] + public void SummarizeSelection_StaleHandlesAreSkippedSilently() + { + var entities = new EntityManager(4); + EntityId living = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(3), role: UnitRole.Harvester); + EntityId stale = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(3), role: UnitRole.Builder); + entities.DespawnUnit(stale); // the version bump invalidates the old handle + + var presenter = new CommandCardPresenter(); + var groups = new SelectionGroup[4]; + int count = presenter.SummarizeSelection(new[] { living, stale }, entities, groups); + + Assert.AreEqual(1, count); + Assert.AreEqual(UnitRole.Harvester, groups[0].Role); + Assert.AreEqual(1, groups[0].Count); + Assert.AreEqual(0, presenter.SummarizeSelection(new[] { living }, null, groups), "no store, no rows"); + } + + [Test] + public void SummarizeSelection_BuildingsGetTheirOwnGroups() + { + var entities = new EntityManager(4); + EntityId hq = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(0), role: UnitRole.HQ); + EntityId tank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(3), role: UnitRole.LightTank); + + var presenter = new CommandCardPresenter(); + var groups = new SelectionGroup[4]; + int count = presenter.SummarizeSelection(new[] { hq, tank }, entities, groups); + + Assert.AreEqual(2, count); + Assert.AreEqual(UnitRole.HQ, groups[0].Role, "first occurrence — the building lead stays on top"); + Assert.AreEqual(1, groups[0].Count); + Assert.AreEqual(UnitRole.LightTank, groups[1].Role); + Assert.AreEqual(1, groups[1].Count); + } + + [Test] + public void FormatSelectionGroup_UnitsShowCountAndHpSums_BuildingsStayBystanders() + { + // The sprint's own example row. + var tankGroup = new SelectionGroup { Role = UnitRole.LightTank, Count = 2, CurrentHealthSum = 180, MaxHealthSum = 240 }; + Assert.AreEqual("2× Lynx — 180/240 HP", CommandCardPresenter.FormatSelectionGroup(FactionId.Alliance, tankGroup)); + + var buildingGroup = new SelectionGroup { Role = UnitRole.HQ, Count = 1, CurrentHealthSum = 900, MaxHealthSum = 1000 }; + Assert.AreEqual("1× Hauptquartier — Gebäude", CommandCardPresenter.FormatSelectionGroup(FactionId.Alliance, buildingGroup)); + } } } diff --git a/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs b/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs index 54fbe4f..0342d6c 100644 --- a/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs +++ b/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs @@ -32,6 +32,22 @@ public enum CommandButtonType InstallDefenseModule = 1 << 9, } + /// + /// One row of the selection breakdown (21.5, #88): all selected entities + /// sharing one role, with their count and summed HP. The group order + /// follows the FIRST OCCURRENCE in the selection — the selection order + /// is stable, so the lead type stays the top row. Buildings get their + /// own groups; they contribute no commands + /// (). + /// + public struct SelectionGroup + { + public UnitRole Role; + public int Count; + public int CurrentHealthSum; + public int MaxHealthSum; + } + /// /// Why a production button is blocked, in the sim's own validation order /// (mirror of ): the T2 @@ -96,7 +112,9 @@ public enum BuildingRepairBlocker /// is returned as PRESENT for the DefensePlatform so the HUD can show it /// deliberately disabled — the schema-v1 kind exists but the sim rejects /// it with RejectedPrerequisitesNotMet in this slice (defense modules are - /// G2/G4 content), so it must never be dispatched. + /// G2/G4 content), so it must never be dispatched. A MULTI-selection + /// intersects these per-role sets instead of taking the lead's word + /// (21.5, #88, ). /// /// /// NOT REPRESENTABLE in schema v1 (open design questions, deliberately @@ -158,6 +176,101 @@ public CommandButtonType GetUnitCommands(FactionId faction, UnitRole leadRole) return commands; } + /// + /// The unit card's buttons for a MULTI-selection (21.5, #88): the + /// intersection of over every MOBILE + /// role in the selection — a command is only offered when it is + /// meaningful for ALL selected units, so the card stops depending + /// on the selection's order (previously the lead unit alone + /// decided). Building roles are SKIPPED before intersecting: + /// returns + /// for them, which would wipe + /// the intersection — in a mixed selection buildings ride along as + /// bystanders (the input discipline of RtsDeviceInput's lead-producer + /// rule) and contribute no commands. + /// + /// Consequences, both deliberate (the sprint's reading: only + /// commands that hold for everyone): Harvest/ReturnCargo appear only + /// on a PURE Harvester selection — which also retires the dead + /// Harvest button on mixed selections, the executor rejects Harvest + /// for non-Harvesters anyway — and Repair only on a pure Builder + /// selection. An input without any mobile role yields + /// . + /// + /// + public CommandButtonType GetSharedUnitCommands(FactionId faction, ReadOnlySpan roles) + { + CommandButtonType shared = CommandButtonType.None; + bool hasMobileRole = false; + for (int i = 0; i < roles.Length; i++) + { + if (SimDefinitions.IsBuildingRole(roles[i])) continue; // bystander, no command vote + CommandButtonType roleCommands = GetUnitCommands(faction, roles[i]); + shared = hasMobileRole ? shared & roleCommands : roleCommands; + hasMobileRole = true; + } + return hasMobileRole ? shared : CommandButtonType.None; + } + + /// + /// Groups a selection by role for the unit card's breakdown rows + /// (21.5, #88): count and summed current/max HP per role, in + /// first-occurrence order (the selection order is stable, so the + /// lead type stays the top row). Stale handles are skipped silently + /// via the miss. Returns the + /// number of groups written into + /// (capped at its length). + /// + public int SummarizeSelection(ReadOnlySpan selection, EntityManager entities, SelectionGroup[] destination) + { + if (destination == null) throw new ArgumentNullException(nameof(destination)); + if (entities == null) return 0; + + int groupCount = 0; + for (int i = 0; i < selection.Length; i++) + { + if (!entities.TryGetUnit(selection[i], out UnitState unit)) continue; // stale handle + + int groupIndex = -1; + for (int g = 0; g < groupCount; g++) + { + if (destination[g].Role == unit.Role) + { + groupIndex = g; + break; + } + } + if (groupIndex < 0) + { + if (groupCount >= destination.Length) continue; // count what fits, never overflow + groupIndex = groupCount++; + destination[groupIndex] = new SelectionGroup { Role = unit.Role }; + } + + SelectionGroup group = destination[groupIndex]; + group.Count++; + group.CurrentHealthSum += unit.CurrentHealth; + group.MaxHealthSum += unit.MaxHealth; + destination[groupIndex] = group; + } + return groupCount; + } + + /// + /// One breakdown row of the unit card (21.5, #88): mobile roles as + /// "2× Lynx — 180/240 HP" (the group's summed HP), buildings as + /// "1× Hauptquartier — Gebäude" — bystanders carry no HP line, the + /// marker is their whole statement on a unit card. + /// + public static string FormatSelectionGroup(FactionId faction, in SelectionGroup group) + { + if (SimDefinitions.IsBuildingRole(group.Role)) + { + return $"{group.Count}× {BuildingDisplayName(group.Role)} — Gebäude"; + } + return $"{group.Count}× {UnitDisplayName(faction, group.Role)} — {group.CurrentHealthSum}/{group.MaxHealthSum} HP"; + } + /// The command buttons of a CONSTRUCTION SITE (definition role with an active site-register row): only cancelling is meaningful. public CommandButtonType GetSiteCommands() { diff --git a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs index 7c04ecb..ed56e57 100644 --- a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs @@ -24,7 +24,14 @@ namespace Nova.Presentation.UI /// (the testable, Unity-free brain); /// this component only renders it and evaluates live state. A mobile lead /// unit gets Move/Stop, Attack when armed, Harvest/ReturnCargo on the - /// Harvester and Repair on the Builder. A completed own building gets + /// Harvester and Repair on the Builder — and since 21.5 (#88) a + /// MULTI-selection intersects those sets over all mobile roles (the + /// lead's slot no longer decides alone), titles "{lead type} — N + /// Einheiten" and lists the per-type breakdown rows with count and + /// summed HP; mixed-in buildings ride along as bystander rows, only an + /// all-building selection keeps the building card. A single damaged + /// unit carries its HP in the title, same convention as the building + /// card. A completed own building gets /// Sell (50% refund) and Repair (greyed with the reason when undamaged /// or when no Builder exists), plus — for producers — one production /// button per unit the building builds (from @@ -124,6 +131,8 @@ private sealed class CardModel public string BuildingPowerText; /// The field card's reserve line ("6.420 / 9.000 AE"); null on every entity card. public string FieldReserveText; + /// Per-type breakdown rows of a multi-entity selection (21.5, #88), first-occurrence order; empty otherwise. + public readonly List SelectionRows = new List(8); public readonly List Buttons = new List(16); public string QueueHeader; public readonly List QueueRows = new List(ProductionSystem.MaxQueueEntries); @@ -142,6 +151,7 @@ public void Clear() LeadId = EntityId.Invalid; BuildingPowerText = null; FieldReserveText = null; + SelectionRows.Clear(); Buttons.Clear(); QueueHeader = null; QueueRows.Clear(); @@ -170,6 +180,10 @@ public void Clear() private readonly StringBuilder _builder = new StringBuilder(96); private readonly CardModel _model = new CardModel(); private readonly SimUnitDefinition[] _producibleScratch = new SimUnitDefinition[SimDefinitions.UnitsPerFaction]; + // 21.5 (#88): selection breakdown scratch — one group per distinct + // role, so MaxSelectedEntities always fits. + private readonly SelectionGroup[] _selectionGroupScratch = new SelectionGroup[SelectionManager.MaxSelectedEntities]; + private readonly UnitRole[] _roleScratch = new UnitRole[SelectionManager.MaxSelectedEntities]; private int _modelFrame = -1; private Rect _lastPanelRect; @@ -247,23 +261,41 @@ private void BuildModel(CardModel model) FactionId faction = _runner.Economy != null ? _runner.Economy.GetSlotFaction(slot) : FactionId.Alliance; + + // 21.5 (#88): one MOBILE unit in the selection makes this a + // unit selection — mixed-in buildings ride along as bystanders + // (the input discipline of RtsDeviceInput.TryGetLeadProducer): + // they get their own breakdown rows but no command vote. Only + // an ALL-BUILDING selection keeps the lead's site/building card. + bool hasMobileUnit = false; + for (int i = 0; i < selected.Length; i++) + { + if (entities.TryGetUnit(selected[i], out UnitState candidate) + && !SimDefinitions.IsBuildingRole(candidate.Role)) + { + hasMobileUnit = true; + break; + } + } + uint rawLead = UnitCommandStateView.ToRawEntityId(lead.Id); model.LeadId = lead.Id; model.Visible = true; - if (_runner.Construction != null + if (!hasMobileUnit + && _runner.Construction != null && _runner.Construction.TryGetSite(rawLead, out ushort siteDefId, out int siteProgressRaw, out uint siteBuilderRaw)) { BuildSiteModel(model, siteDefId, siteProgressRaw, siteBuilderRaw, slot, entities); } - else if (SimDefinitions.IsBuildingRole(lead.Role)) + else if (!hasMobileUnit && SimDefinitions.IsBuildingRole(lead.Role)) { BuildBuildingModel(model, in lead, rawLead, slot, faction, entities); } else { - BuildUnitModel(model, faction, lead.Role, selected.Length); + BuildUnitModel(model, faction, in lead, selected, entities); } if (_input.OrderPickModeActive) @@ -296,13 +328,61 @@ private void BuildFieldModel(CardModel model, ushort fieldId) model.Visible = true; } - /// The unit card: the lead unit's role decides the buttons (armed? harvester? builder?). - private void BuildUnitModel(CardModel model, FactionId faction, UnitRole leadRole, int selectedCount) + /// + /// The unit card (21.5, #88). A single selection keeps the familiar + /// title and borrows the building card's damaged-HP title convention + /// ("{cur}/{max} HP", only when damaged). A multi-selection titles + /// "{lead type} — N Einheiten" (mobile units counted; building + /// bystanders are LISTED, not counted as units) and shows the + /// per-type breakdown rows — the honest replacement for the old + /// "(+N weitere)" suffix. The buttons are the INTERSECTION over all + /// mobile roles of the selection + /// (), so + /// they no longer depend on the selection order. + /// + private void BuildUnitModel(CardModel model, FactionId faction, in UnitState lead, ReadOnlySpan selected, EntityManager entities) { - model.Title = CommandCardPresenter.UnitDisplayName(faction, leadRole); - if (selectedCount > 1) model.Title += $" (+{selectedCount - 1} weitere)"; + int groupCount = _presenter.SummarizeSelection(selected, entities, _selectionGroupScratch); + + int liveCount = 0; + int mobileCount = 0; + UnitRole firstMobileRole = lead.Role; + for (int i = 0; i < groupCount; i++) + { + liveCount += _selectionGroupScratch[i].Count; + if (!SimDefinitions.IsBuildingRole(_selectionGroupScratch[i].Role)) + { + if (mobileCount == 0) firstMobileRole = _selectionGroupScratch[i].Role; + mobileCount += _selectionGroupScratch[i].Count; + } + } + + if (liveCount <= 1) + { + model.Title = CommandCardPresenter.UnitDisplayName(faction, lead.Role); + if (lead.CurrentHealth < lead.MaxHealth) + { + model.Title += $" {lead.CurrentHealth}/{lead.MaxHealth} HP"; + } + } + else + { + model.Title = $"{CommandCardPresenter.UnitDisplayName(faction, firstMobileRole)} — {mobileCount} Einheiten"; + for (int i = 0; i < groupCount; i++) + { + model.SelectionRows.Add(CommandCardPresenter.FormatSelectionGroup(faction, in _selectionGroupScratch[i])); + } + } - CommandButtonType commands = _presenter.GetUnitCommands(faction, leadRole); + // The intersection votes once per ROLE — repeating a role per + // unit would not change the AND, so the groups feed it directly. + int roleCount = 0; + for (int i = 0; i < groupCount && roleCount < _roleScratch.Length; i++) + { + _roleScratch[roleCount++] = _selectionGroupScratch[i].Role; + } + + CommandButtonType commands = _presenter.GetSharedUnitCommands(faction, _roleScratch.AsSpan(0, roleCount)); if (commands.HasFlag(CommandButtonType.Move)) AddButton(model, "Bewegen (RMB)", true, CardAction.MovePick); if (commands.HasFlag(CommandButtonType.Stop)) AddButton(model, "Stopp (S)", true, CardAction.Stop); if (commands.HasFlag(CommandButtonType.Attack)) AddButton(model, "Angreifen (A)", true, CardAction.AttackPick); @@ -542,6 +622,10 @@ private void OnGUI() { GUILayout.Label(model.FieldReserveText, _rowStyle, GUILayout.Height(RowHeight)); } + for (int i = 0; i < model.SelectionRows.Count; i++) + { + GUILayout.Label(model.SelectionRows[i], _rowStyle, GUILayout.Height(RowHeight)); + } if (model.ProgressBar01 >= 0f) DrawProgressBar(model.ProgressBar01); if (model.SiteStatusText != null) { @@ -644,6 +728,12 @@ private float EstimateHeight(CardModel model) height += TitleHeight + _titleStyle.margin.vertical; if (model.BuildingPowerText != null) height += RowHeight + _rowStyle.margin.vertical; if (model.FieldReserveText != null) height += RowHeight + _rowStyle.margin.vertical; + for (int i = 0; i < model.SelectionRows.Count; i++) + { + // Row for row with OnGUI: each breakdown row costs its + // content height PLUS the row style's vertical margin. + 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/CHANGELOG.md b/CHANGELOG.md index 7c5f02c..5e48caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,15 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de spielerisch abgenommen und kein Meilenstein-Nachweis ### Hinzugefügt +- **Die Befehlskarte sagt die Wahrheit über die Auswahl (Paket 21.5, #88).** + Bei Mehrfachauswahl bietet die Einheitenkarte nur noch Befehle an, die für + **alle** markierten mobilen Einheiten gelten (Schnittmenge statt + Anführer-Logik — „Ernten" erscheint konsequent nur bei reiner + Sammler-Auswahl, „Reparieren" nur bei reiner Pionier-Auswahl), listet die + Auswahl pro Typ auf („2× Lynx — 180/240 HP", mitmarkierte Gebäude als + eigene Bystander-Zeile ohne Befehlsbeitrag) und trägt bei einer + beschädigten Einzelauswahl deren HP im Titel; rein sim-lesend, ohne + Eingriff in die Simulation - **Restbestand der Vorkommen anklickbar und sichtbar (Paket 21.2, #86).** Ein Linksklick auf ein Aetherium-Vorkommen (auch ein erschöpftes) zeigt in der Befehlskarte Restbestand und Anfangsreserve („Aetherium-Vorkommen — 6.420 /