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 /