diff --git a/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs b/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs
new file mode 100644
index 0000000..3f252d6
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs
@@ -0,0 +1,231 @@
+using System;
+using NUnit.Framework;
+using Nova.Core;
+using Nova.Gameplay;
+using Nova.Simulation;
+using Nova.Simulation.Construction;
+using Nova.Simulation.Economy;
+using Nova.Simulation.Pathfinding;
+using Nova.Simulation.State;
+
+namespace Nova.Gameplay.Tests
+{
+ ///
+ /// Contract tests for (sprint 22, #50):
+ /// the entity-side idle predicate against the four standing-order
+ /// markers the Stop command clears, the construction-site assignment
+ /// collection against a REAL site (placed and ticked through the
+ /// kernel, so the sim's own auto-assignment names the busy Builder),
+ /// and the deterministic ascending-index cycle with its single wrap.
+ /// The documented blind spot (a standing repair order is not observable
+ /// outside ConstructionSystem) is deliberately NOT approximated here —
+ /// no test pins a repairing Builder as busy, because the query cannot
+ /// see him; the class docstring and the sprint report carry that.
+ ///
+ [TestFixture]
+ public class IdleBuilderQueryTests
+ {
+ ///
+ /// A live construction domain, mirroring ConstructionSystemTests'
+ /// fixture: entity store, economy, cost field and kernel, so site
+ /// creation and Builder auto-assignment run the sim's own code path.
+ ///
+ private sealed class Fixture
+ {
+ public EntityManager Entities { get; }
+ public ConstructionSystem Construction { get; }
+ public SimulationKernel Kernel { get; }
+
+ public Fixture()
+ {
+ Entities = new EntityManager(64);
+ var economy = new EconomySystem(Entities, 1000);
+ var costField = new CostField(ConstructionSystem.GridSize, ConstructionSystem.GridSize);
+ Construction = new ConstructionSystem(Entities, economy, costField);
+ Kernel = new SimulationKernel(new SimRandom(42UL));
+ Kernel.RegisterSystem(economy);
+ Kernel.RegisterSystem(Construction);
+ if (economy.FieldCount == 0)
+ {
+ economy.TryAddField(63, new GridPos2D(20, 24), 9000);
+ }
+ Kernel.Start();
+ }
+
+ public EntityId SpawnBuilder(byte slot, int x, int y)
+ {
+ return Entities.SpawnUnit(
+ slot,
+ new Transform2D(SimFixed.FromInt(x), SimFixed.FromInt(y)),
+ SimFixed.FromInt(3),
+ role: UnitRole.Builder);
+ }
+ }
+
+ // ----------------------------------------------------------------
+ // The entity-side predicate (the four markers Stop clears)
+ // ----------------------------------------------------------------
+
+ [Test]
+ public void HasNoEntitySideOrder_FreshBuilder_IsIdle()
+ {
+ var entities = new EntityManager(8);
+ entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(3), role: UnitRole.Builder);
+ ref readonly UnitState builder = ref entities.RawUnits[0];
+
+ Assert.IsTrue(IdleBuilderQuery.HasNoEntitySideOrder(in builder));
+ }
+
+ [Test]
+ public void HasNoEntitySideOrder_AnyStandingOrder_IsBusy()
+ {
+ var entities = new EntityManager(8);
+ EntityId mover = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(3), role: UnitRole.Builder);
+ EntityId attacker = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(3), role: UnitRole.Builder);
+ EntityId harvester = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(3), SimFixed.FromInt(3)), SimFixed.FromInt(3), role: UnitRole.Builder);
+ EntityId returner = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(4), SimFixed.FromInt(4)), SimFixed.FromInt(3), role: UnitRole.Builder);
+ EntityId target = entities.SpawnUnit(1, new Transform2D(SimFixed.FromInt(5), SimFixed.FromInt(5)), SimFixed.FromInt(3), role: UnitRole.LightTank);
+
+ entities.GetUnitRef(mover).SetTarget(new GridPos2D(7, 7));
+ entities.GetUnitRef(attacker).AttackTarget = target;
+ // A Builder never legitimately holds the economy orders — the
+ // predicate still reads them as busy (the view's Apply does not
+ // role-filter, so a state that should not exist must never pass
+ // as free labour).
+ entities.GetUnitRef(harvester).HarvestFieldId = 1;
+ entities.GetUnitRef(returner).IsReturningCargo = true;
+
+ Assert.IsFalse(IdleBuilderQuery.HasNoEntitySideOrder(in entities.RawUnits[mover.Index]), "movement order");
+ Assert.IsFalse(IdleBuilderQuery.HasNoEntitySideOrder(in entities.RawUnits[attacker.Index]), "attack order");
+ Assert.IsFalse(IdleBuilderQuery.HasNoEntitySideOrder(in entities.RawUnits[harvester.Index]), "harvest order");
+ Assert.IsFalse(IdleBuilderQuery.HasNoEntitySideOrder(in entities.RawUnits[returner.Index]), "return-cargo order");
+ }
+
+ // ----------------------------------------------------------------
+ // Site assignment collection (construction-side marker)
+ // ----------------------------------------------------------------
+
+ [Test]
+ public void CollectAssignedBuilderRaws_NoSites_ReturnsEmpty()
+ {
+ var f = new Fixture();
+ f.SpawnBuilder(0, 30, 30);
+ var scratch = new uint[ConstructionSystem.MaxSites];
+
+ Assert.AreEqual(0, IdleBuilderQuery.CollectAssignedBuilderRaws(f.Entities, f.Construction, scratch));
+ Assert.AreEqual(0, IdleBuilderQuery.CollectAssignedBuilderRaws(null, f.Construction, scratch), "no store, no raws");
+ Assert.AreEqual(0, IdleBuilderQuery.CollectAssignedBuilderRaws(f.Entities, null, scratch), "no construction, no raws");
+ }
+
+ [Test]
+ public void CollectAssignedBuilderRaws_ActiveSite_HoldsTheAutoAssignedBuilder()
+ {
+ var f = new Fixture();
+ // The existing suite's placement pair: a completed HQ anchors
+ // the build influence, a Power site lands at (10,10).
+ Assert.IsTrue(f.Construction.PlaceCompletedBuilding(0, 3, 0, 10).IsValid, "HQ influence anchor");
+ EntityId builder = f.SpawnBuilder(0, 30, 30);
+ Assert.IsTrue(f.Construction.TryPlaceBuilding(0, 5, 10, 10), "Power site placement");
+
+ f.Kernel.StepTick(); // ProgressSites auto-assigns the lowest-index own Builder
+
+ var scratch = new uint[ConstructionSystem.MaxSites];
+ int count = IdleBuilderQuery.CollectAssignedBuilderRaws(f.Entities, f.Construction, scratch);
+
+ Assert.AreEqual(1, count, "one active site, one assigned Builder");
+ Assert.AreEqual(UnitCommandStateView.ToRawEntityId(builder), scratch[0]);
+ }
+
+ [Test]
+ public void IsIdleBuilder_SiteAssignment_IsTheConstructionSideBusyMarker()
+ {
+ var f = new Fixture();
+ Assert.IsTrue(f.Construction.PlaceCompletedBuilding(0, 3, 0, 10).IsValid, "HQ influence anchor");
+ EntityId assigned = f.SpawnBuilder(0, 30, 30);
+ EntityId free = f.SpawnBuilder(0, 40, 40);
+ Assert.IsTrue(f.Construction.TryPlaceBuilding(0, 5, 10, 10), "Power site placement");
+ f.Kernel.StepTick(); // the lowest-index Builder (assigned) gets the site
+
+ var scratch = new uint[ConstructionSystem.MaxSites];
+ int count = IdleBuilderQuery.CollectAssignedBuilderRaws(f.Entities, f.Construction, scratch);
+
+ Assert.IsFalse(
+ IdleBuilderQuery.IsIdleBuilder(in f.Entities.RawUnits[assigned.Index], 0, scratch.AsSpan(0, count)),
+ "a Builder standing still but held by a site is BUSY — his feet do not decide");
+ Assert.IsTrue(
+ IdleBuilderQuery.IsIdleBuilder(in f.Entities.RawUnits[free.Index], 0, scratch.AsSpan(0, count)),
+ "the unassigned Builder with no orders is idle");
+ }
+
+ // ----------------------------------------------------------------
+ // The deterministic cycle
+ // ----------------------------------------------------------------
+
+ [Test]
+ public void TryFindNextIdleBuilder_SkipsBusyForeignAndNonBuilders()
+ {
+ var f = new Fixture();
+ EntityId mover = f.SpawnBuilder(0, 1, 1); // index 0: busy (move order)
+ f.Entities.GetUnitRef(mover).SetTarget(new GridPos2D(7, 7));
+ f.Entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(2), role: UnitRole.Harvester); // index 1: wrong role
+ f.SpawnBuilder(1, 3, 3); // index 2: foreign
+ EntityId expected = f.SpawnBuilder(0, 4, 4); // index 3: the only idle own Builder
+ var scratch = new uint[ConstructionSystem.MaxSites];
+
+ bool found = IdleBuilderQuery.TryFindNextIdleBuilder(
+ f.Entities, f.Construction, 0, -1, scratch, out EntityId builder);
+
+ Assert.IsTrue(found);
+ Assert.AreEqual(expected, builder);
+ }
+
+ [Test]
+ public void TryFindNextIdleBuilder_ToursAscendingAndWrapsOnce()
+ {
+ var f = new Fixture();
+ EntityId first = f.SpawnBuilder(0, 1, 1);
+ EntityId second = f.SpawnBuilder(0, 2, 2);
+ EntityId third = f.SpawnBuilder(0, 3, 3);
+ var scratch = new uint[ConstructionSystem.MaxSites];
+
+ Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, -1, scratch, out EntityId tour));
+ Assert.AreEqual(first, tour, "a fresh tour starts at the lowest index");
+ Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, tour.Index, scratch, out tour));
+ Assert.AreEqual(second, tour, "strictly after the previous index");
+ Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, tour.Index, scratch, out tour));
+ Assert.AreEqual(third, tour);
+ Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, tour.Index, scratch, out tour));
+ Assert.AreEqual(first, tour, "the round wraps to the bottom exactly once");
+ }
+
+ [Test]
+ public void TryFindNextIdleBuilder_SoleIdleBuilder_IsReturnedEveryPress()
+ {
+ var f = new Fixture();
+ EntityId only = f.SpawnBuilder(0, 1, 1);
+ EntityId mover = f.SpawnBuilder(0, 2, 2);
+ f.Entities.GetUnitRef(mover).SetTarget(new GridPos2D(7, 7));
+ var scratch = new uint[ConstructionSystem.MaxSites];
+
+ Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, -1, scratch, out EntityId tour));
+ Assert.AreEqual(only, tour);
+ Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, tour.Index, scratch, out tour));
+ Assert.AreEqual(only, tour, "one idle Builder is the whole round");
+ }
+
+ [Test]
+ public void TryFindNextIdleBuilder_NoneIdle_ReturnsFalse()
+ {
+ var f = new Fixture();
+ EntityId mover = f.SpawnBuilder(0, 1, 1);
+ f.Entities.GetUnitRef(mover).SetTarget(new GridPos2D(7, 7));
+ var scratch = new uint[ConstructionSystem.MaxSites];
+
+ Assert.IsFalse(IdleBuilderQuery.TryFindNextIdleBuilder(
+ f.Entities, f.Construction, 0, -1, scratch, out EntityId builder));
+ Assert.IsFalse(builder.IsValid);
+ Assert.IsFalse(IdleBuilderQuery.TryFindNextIdleBuilder(null, f.Construction, 0, -1, scratch, out _), "no store");
+ Assert.IsFalse(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, null, 0, -1, scratch, out _), "no construction");
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs.meta b/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs.meta
new file mode 100644
index 0000000..c84c742
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3c2b1a0f9e8d7c6b5a4938274655abc1
diff --git a/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs b/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs
index c04a74d..dc24841 100644
--- a/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs
+++ b/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs
@@ -213,5 +213,79 @@ public void SelectionManager_ClearSelection_ClearsFieldAndEntities()
selection.ClearSelection();
Assert.AreEqual((ushort)0, selection.SelectedFieldId, "the ingress rebind relies on ClearSelection dropping the field too");
}
+
+ // ------------------------------------------------------------------
+ // Sprint 22 (#50): type-row filter + double-click role select
+ // ------------------------------------------------------------------
+
+ [Test]
+ public void SelectionManager_RetainRole_KeepsOnlyThatRoleInStableOrder()
+ {
+ var entities = new EntityManager(10);
+ var selection = new SelectionManager();
+ EntityId firstTank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(3), role: UnitRole.LightTank);
+ EntityId harvester = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(12), SimFixed.FromInt(12)), SimFixed.FromInt(2), role: UnitRole.Harvester);
+ EntityId secondTank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(14), SimFixed.FromInt(14)), SimFixed.FromInt(3), role: UnitRole.LightTank);
+ selection.SelectBox(entities, playerId: 0, minX: 0f, minY: 0f, maxX: 20f, maxY: 20f);
+ Assert.AreEqual(3, selection.SelectedCount);
+
+ int kept = selection.RetainRole(entities, UnitRole.LightTank);
+
+ Assert.AreEqual(2, kept, "the row click reduces the selection to the row's type");
+ Assert.AreEqual(firstTank, selection.SelectedEntities[0], "the selection order is stable, so the first tank leads");
+ Assert.AreEqual(secondTank, selection.SelectedEntities[1]);
+ }
+
+ [Test]
+ public void SelectionManager_RetainRole_DropsStaleHandlesAndAbsentRoles()
+ {
+ var entities = new EntityManager(10);
+ var selection = new SelectionManager();
+ EntityId dying = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(3), role: UnitRole.LightTank);
+ EntityId living = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(12), SimFixed.FromInt(12)), SimFixed.FromInt(3), role: UnitRole.LightTank);
+ selection.SelectBox(entities, playerId: 0, minX: 0f, minY: 0f, maxX: 20f, maxY: 20f);
+ entities.DespawnUnit(dying); // died between the card's model build and the row click
+
+ Assert.AreEqual(1, selection.RetainRole(entities, UnitRole.LightTank), "the stale handle is dropped against the live store");
+ Assert.AreEqual(living, selection.SelectedEntities[0]);
+
+ Assert.AreEqual(0, selection.RetainRole(entities, UnitRole.Harvester), "a role nothing selected has leaves an empty selection");
+ Assert.AreEqual(0, selection.SelectedCount);
+ }
+
+ [Test]
+ public void SelectionManager_ReplaceSelection_ReplacesDedupesAndClearsField()
+ {
+ var entities = new EntityManager(10);
+ var selection = new SelectionManager();
+ EntityId u1 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(5));
+ EntityId u2 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(12), SimFixed.FromInt(12)), SimFixed.FromInt(5));
+ EntityId u3 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(14), SimFixed.FromInt(14)), SimFixed.FromInt(5));
+ selection.SelectField(3); // a field readout owns the card before the gesture
+
+ int count = selection.ReplaceSelection(new[] { u2, u3, u2 });
+
+ Assert.AreEqual(2, count, "duplicates collapse through AddSingle");
+ Assert.AreEqual(u2, selection.SelectedEntities[0], "the new list leads, u1 never joined it");
+ Assert.AreEqual((ushort)0, selection.SelectedFieldId, "an entity selection ends the field selection");
+ }
+
+ [Test]
+ public void SelectionManager_AddRange_UnionsLikeShiftClick()
+ {
+ var entities = new EntityManager(10);
+ var selection = new SelectionManager();
+ EntityId u1 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(5));
+ EntityId u2 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(12), SimFixed.FromInt(12)), SimFixed.FromInt(5));
+ EntityId u3 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(14), SimFixed.FromInt(14)), SimFixed.FromInt(5));
+ selection.SelectSingle(u1);
+
+ int count = selection.AddRange(new[] { u2, u1, u3 });
+
+ Assert.AreEqual(3, count, "the new ids join, the already-selected one is not duplicated");
+ Assert.AreEqual(u1, selection.SelectedEntities[0], "the existing selection keeps its lead");
+ Assert.AreEqual(u2, selection.SelectedEntities[1]);
+ Assert.AreEqual(u3, selection.SelectedEntities[2]);
+ }
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs b/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs
new file mode 100644
index 0000000..0b455d1
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs
@@ -0,0 +1,181 @@
+using System;
+using Nova.Core;
+using Nova.Simulation.Construction;
+using Nova.Simulation.State;
+
+namespace Nova.Gameplay
+{
+ ///
+ /// The "next idle Builder" query behind the I key (sprint 22, #50 — the
+ /// beta report's build-flow break: the player lost the Builder in a clump
+ /// and could not build). Pure, Unity-free and allocation-free on purpose,
+ /// the same precedent as : the whole
+ /// predicate and the cycling order are EditMode-tested here, the device
+ /// input only wires the key and the camera.
+ ///
+ /// "IDLE" AS READ FROM THE CODE, not from intuition — a Builder is idle
+ /// when he carries NONE of the standing-order markers the sim knows. The
+ /// entity store carries four, and the Stop command's clearing list
+ /// (, Stop case) is the sim's own
+ /// enumeration of them: (a movement
+ /// order; set by SetTarget, cleared by arrival and by Stop),
+ /// (an attack order),
+ /// and
+ /// (the two economy orders — a
+ /// Builder never legitimately holds these, but the predicate checks them
+ /// anyway: the view's Apply does not role-filter, so a state that
+ /// should not exist must still read as busy, never as free labour). The
+ /// fifth marker lives construction-side: a site's
+ /// AssignedBuilderRaw — the builder a site holds is building (or
+ /// is expected to walk there), readable per site via
+ /// and collected here by
+ /// .
+ ///
+ ///
+ /// KNOWN BLIND SPOT, reported instead of approximated: the sixth marker,
+ /// a standing REPAIR order, is not observable from this layer. It lives
+ /// in ConstructionSystem's private repair table; the only public surface
+ /// is write-side (AssignRepairOrder/ClearRepairOrder) plus a global
+ /// capacity count, and the repair tick mutates the TARGET's health, never
+ /// the Builder's UnitState. Closing this needs a one-line public reader
+ /// inside Simulation/** — out of scope for #50, which is selection and
+ /// presentation only. So a Builder mid-repair with his feet still reads
+ /// as idle here. That is a deliberate, documented wrong answer in exactly
+ /// one occasional state, not a guess: the predicate is shaped so the
+ /// future repair read slots in as one more clause, and the report names
+ /// the seam.
+ ///
+ ///
+ /// CYCLING: walks entity indices
+ /// ascending — the entity store's own order, stable and reproducible —
+ /// starting strictly after the previously returned index and wrapping
+ /// once, so repeated presses tour every idle Builder in a deterministic
+ /// round. A Builder who is the only idle one is returned every press.
+ ///
+ ///
+ public static class IdleBuilderQuery
+ {
+ ///
+ /// The entity-store half of idle: no movement, attack or economy
+ /// order on the unit itself. Construction-side markers (site
+ /// assignment) are NOT visible here — they arrive as
+ /// in
+ /// ; the repair blind spot is documented
+ /// on the class.
+ ///
+ public static bool HasNoEntitySideOrder(in UnitState unit)
+ {
+ return !unit.IsMoving
+ && !unit.AttackTarget.IsValid
+ && unit.HarvestFieldId == 0
+ && !unit.IsReturningCargo;
+ }
+
+ ///
+ /// Collects the AssignedBuilderRaw of every active
+ /// construction site into and returns
+ /// the count written (capped at the destination length; sized to
+ /// nothing is ever dropped).
+ /// Sites of ANY owner are collected: the membership test in
+ /// only ever runs against the local
+ /// player's Builders, and a raw id (index + version) cannot alias a
+ /// different living entity, so a foreign site's row can never mark an
+ /// own Builder busy. One read pass per key press — the entity scan
+ /// asks of every active
+ /// entity, and a non-site misses the site register immediately.
+ ///
+ public static int CollectAssignedBuilderRaws(
+ EntityManager entities, ConstructionSystem construction, uint[] destination)
+ {
+ if (destination == null) throw new ArgumentNullException(nameof(destination));
+ if (entities == null || construction == null) return 0;
+
+ int written = 0;
+ UnitState[] units = entities.RawUnits;
+ int capacity = entities.Capacity;
+ for (int i = 0; i < capacity && written < destination.Length; i++)
+ {
+ ref readonly UnitState unit = ref units[i];
+ if (!unit.IsActive) continue;
+ if (construction.TryGetSite(
+ UnitCommandStateView.ToRawEntityId(unit.Id),
+ out _, out _, out uint assignedBuilderRaw)
+ && assignedBuilderRaw != 0)
+ {
+ destination[written++] = assignedBuilderRaw;
+ }
+ }
+ return written;
+ }
+
+ ///
+ /// The full idle predicate for one unit: an ACTIVE Builder of
+ /// with no entity-side order
+ /// () and no site assignment in
+ /// (as collected by
+ /// ). The unobservable
+ /// standing repair order is the documented exception (class remarks).
+ ///
+ public static bool IsIdleBuilder(
+ in UnitState unit, byte playerSlot, ReadOnlySpan assignedBuilderRaws)
+ {
+ if (!unit.IsActive || unit.Role != UnitRole.Builder || unit.PlayerId != playerSlot)
+ {
+ return false;
+ }
+ if (!HasNoEntitySideOrder(in unit)) return false;
+
+ uint raw = UnitCommandStateView.ToRawEntityId(unit.Id);
+ for (int i = 0; i < assignedBuilderRaws.Length; i++)
+ {
+ if (assignedBuilderRaws[i] == raw) return false; // a site holds his Bauauftrag
+ }
+ return true;
+ }
+
+ ///
+ /// The next idle Builder strictly after
+ /// in ascending entity-index order, wrapping once to index 0 when the
+ /// tail holds none — the deterministic round the I key tours. Pass -1
+ /// to start at the lowest-index idle Builder.
+ /// is the caller's reusable buffer
+ /// for (sized
+ /// ), so the per-press path
+ /// stays allocation-free.
+ ///
+ public static bool TryFindNextIdleBuilder(
+ EntityManager entities, ConstructionSystem construction, byte playerSlot,
+ int afterIndex, uint[] assignedScratch, out EntityId builder)
+ {
+ builder = EntityId.Invalid;
+ if (entities == null || construction == null || assignedScratch == null) return false;
+
+ int assignedCount = CollectAssignedBuilderRaws(entities, construction, assignedScratch);
+ ReadOnlySpan assigned = assignedScratch.AsSpan(0, assignedCount);
+
+ UnitState[] units = entities.RawUnits;
+ int capacity = entities.Capacity;
+ int start = afterIndex < -1 ? -1 : afterIndex;
+
+ // Two ascending passes — after start, then from 0 up to start —
+ // so the tour is a strict index cycle with a single wrap.
+ for (int i = start + 1; i < capacity; i++)
+ {
+ if (IsIdleBuilder(in units[i], playerSlot, assigned))
+ {
+ builder = units[i].Id;
+ return true;
+ }
+ }
+ for (int i = 0; i <= start && i < capacity; i++)
+ {
+ if (IsIdleBuilder(in units[i], playerSlot, assigned))
+ {
+ builder = units[i].Id;
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs.meta b/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs.meta
new file mode 100644
index 0000000..58702a8
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8f4b2c9a1d3e4f5a6b7c8d9e0f1a2b3c
diff --git a/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs b/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs
index 10363ae..62e2dd9 100644
--- a/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs
+++ b/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs
@@ -113,6 +113,60 @@ public int SelectBoxAdditive(EntityManager entityManager, byte playerId, float m
return _selectedCount;
}
+ ///
+ /// The command card's breakdown-row click (21.5 rows, made clickable
+ /// in sprint 22 for #50): reduces the selection to its entities of
+ /// , preserving the selection's own order (it
+ /// is stable, so the lead of the reduced selection is the first
+ /// selected entity of that role — the same first-occurrence rule the
+ /// rows are drawn in). Stale handles are dropped against the live
+ /// store, the same courtesy applies
+ /// at recall. Returns the surviving count; 0 means the selection is
+ /// empty (the row's units died between model build and click).
+ ///
+ public int RetainRole(EntityManager entityManager, UnitRole role)
+ {
+ if (entityManager == null || _selectedCount == 0) return _selectedCount;
+
+ int kept = 0;
+ for (int i = 0; i < _selectedCount; i++)
+ {
+ EntityId id = _selectedIds[i];
+ if (!entityManager.TryGetUnit(id, out UnitState unit) || unit.Role != role) continue;
+ _selectedIds[kept++] = id;
+ }
+ _selectedCount = kept;
+ return _selectedCount;
+ }
+
+ ///
+ /// Replaces the selection with (deduped and
+ /// capped through , field selection cleared).
+ /// The double-click role select's replace half (sprint 22, #50);
+ /// store-less like — the caller vouches
+ /// for the ids, staleness is filtered on use. Returns the new count.
+ ///
+ public int ReplaceSelection(ReadOnlySpan ids)
+ {
+ ClearSelection();
+ return AddRange(ids);
+ }
+
+ ///
+ /// Adds every id of to the selection (deduped,
+ /// capped, field selection cleared) — the double-click role select's
+ /// Shift half, the same additive discipline Shift-click and
+ /// Shift-drag already follow (sprint 09 §7). Returns the new count.
+ ///
+ public int AddRange(ReadOnlySpan ids)
+ {
+ for (int i = 0; i < ids.Length; i++)
+ {
+ AddSingle(ids[i]);
+ }
+ return _selectedCount;
+ }
+
// ------------------------------------------------------------------
// Control groups (sprint 09 §7)
// ------------------------------------------------------------------
diff --git a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs
index ed56e57..d546b14 100644
--- a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs
+++ b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs
@@ -50,6 +50,20 @@ namespace Nova.Presentation.UI
/// fields named in the title).
///
///
+ /// CLICKABLE BREAKDOWN ROWS (sprint 22, #50): the 21.5 per-type rows are
+ /// no longer read-only — clicking a row reduces the selection to exactly
+ /// that row's role (the card then redraws with that type's commands,
+ /// which is the cheapest path from "I see what is selected" to "I can
+ /// work with it"). The rows are buttons, so the panel hit test
+ /// () already covers them: they live
+ /// inside the same BeginArea, and keeps its
+ /// row-for-row contract because the row-button style carries the LABEL
+ /// style's vertical margin (see EnsureStyles — that coupling is the
+ /// "~40 px short" bug's fence). While a gesture is armed (placement
+ /// ghost or order pick) the filter is refused: the input discipline
+ /// forbids selection changes mid-gesture.
+ ///
+ ///
/// REPAIR FLOW (decided, GB-006): the sim issues repair as a BUILDER-side
/// standing order on an own damaged completed building — there is no
/// building-side assignment. The unit card therefore arms a target pick
@@ -117,6 +131,17 @@ private struct QueueRow
public float Progress01;
}
+ ///
+ /// One clickable breakdown row (sprint 22, #50): the 21.5 label plus
+ /// the role it stands for, so a row click can reduce the selection
+ /// to exactly that type without re-deriving the grouping.
+ ///
+ private struct SelectionRow
+ {
+ public string Label;
+ public UnitRole Role;
+ }
+
///
/// One frame's panel content. Rebuilt at most once per frame and
/// shared between the hit test and the draw, so the two cannot
@@ -131,8 +156,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);
+ /// Per-type breakdown rows of a multi-entity selection (21.5, #88 — clickable since sprint 22, #50), 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);
@@ -191,6 +216,7 @@ public void Clear()
private GUIStyle _buttonStyle;
private GUIStyle _sectionStyle;
private GUIStyle _rowStyle;
+ private GUIStyle _selectionRowStyle;
private GUIStyle _siteStatusStyle;
private GUIStyle _cancelStyle;
private GUIStyle _hintStyle;
@@ -370,8 +396,16 @@ private void BuildUnitModel(CardModel model, FactionId faction, in UnitState lea
model.Title = $"{CommandCardPresenter.UnitDisplayName(faction, firstMobileRole)} — {mobileCount} Einheiten";
for (int i = 0; i < groupCount; i++)
{
- model.SelectionRows.Add(CommandCardPresenter.FormatSelectionGroup(faction, in _selectionGroupScratch[i]));
+ model.SelectionRows.Add(new SelectionRow
+ {
+ Label = CommandCardPresenter.FormatSelectionGroup(faction, in _selectionGroupScratch[i]),
+ Role = _selectionGroupScratch[i].Role,
+ });
}
+ // Rows are clickable (sprint 22, #50) — say so once, where
+ // the gesture lives; an armed order pick overwrites this
+ // with its own hint in BuildModel.
+ model.FooterHint = "Klick auf eine Zeile: nur diesen Typ auswählen";
}
// The intersection votes once per ROLE — repeating a role per
@@ -624,7 +658,17 @@ private void OnGUI()
}
for (int i = 0; i < model.SelectionRows.Count; i++)
{
- GUILayout.Label(model.SelectionRows[i], _rowStyle, GUILayout.Height(RowHeight));
+ // Buttons, not labels (sprint 22, #50): a row click filters
+ // the selection down to that row's role. The row-button
+ // style carries _rowStyle's vertical margin, so
+ // EstimateHeight's per-row cost stays exact (its comment
+ // names the bug that fence guards).
+ SelectionRow row = model.SelectionRows[i];
+ if (GUILayout.Button(row.Label, _selectionRowStyle, GUILayout.Height(RowHeight)))
+ {
+ AudioServiceLocator.Play2D(SoundEventId.UI_Click);
+ _input.FilterSelectionToRole(row.Role);
+ }
}
if (model.ProgressBar01 >= 0f) DrawProgressBar(model.ProgressBar01);
if (model.SiteStatusText != null)
@@ -731,7 +775,12 @@ private float EstimateHeight(CardModel model)
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.
+ // content height PLUS the row style's vertical margin. The
+ // rows are buttons since sprint 22 (#50), but
+ // _selectionRowStyle copies _rowStyle's margin verbatim
+ // (EnsureStyles), so this price stays exact — changing one
+ // style's margin without the other reopens the "~40 px
+ // short: visible, but not clickable" bug above.
height += RowHeight + _rowStyle.margin.vertical;
}
if (model.ProgressBar01 >= 0f) height += ProgressHeight; // GUIStyle.none: no margin
@@ -836,6 +885,24 @@ private void EnsureStyles()
{
_rowStyle = new GUIStyle(GUI.skin.label) { fontSize = 11, wordWrap = false };
}
+ if (_selectionRowStyle == null)
+ {
+ // The clickable breakdown row (sprint 22, #50): a button that
+ // READS like the label rows around it (left-aligned, same
+ // font size) — and carries _rowStyle's exact margin, because
+ // EstimateHeight prices every row as RowHeight plus
+ // _rowStyle.margin.vertical. Any margin drift reopens the
+ // "visible, but not clickable" height bug documented there.
+ _selectionRowStyle = new GUIStyle(GUI.skin.button)
+ {
+ fontSize = 11,
+ wordWrap = false,
+ alignment = TextAnchor.MiddleLeft,
+ margin = new RectOffset(
+ _rowStyle.margin.left, _rowStyle.margin.right,
+ _rowStyle.margin.top, _rowStyle.margin.bottom),
+ };
+ }
if (_siteStatusStyle == null)
{
// Wraps: the no-Builder warning is longer than the panel is
diff --git a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs
index dbb3642..ec5d3ea 100644
--- a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs
+++ b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs
@@ -115,6 +115,8 @@ public sealed class RtsDeviceInput : MonoBehaviour
[SerializeField] private float _pickRadiusWorld = 1.5f;
[Tooltip("Click-select radius for Aetherium fields in world units (= cells). Wider than the unit pick radius because the marker is a seven-shard cluster; exhausted fields stay clickable for their readout (21.2, #86).")]
[SerializeField] private float _fieldPickRadiusWorld = 2f;
+ [Tooltip("Seconds between two clicks on the same unit that read as a double-click — that selects all own units of its role visible in the current camera image (sprint 22, #50).")]
+ [SerializeField] private float _doubleClickSeconds = 0.35f;
[Header("Canonical Alliance definition ids (resolved to the local slot faction at runtime)")]
[Tooltip("B: Power — Alliance defId 5, 450 AE, prerequisite-free.")]
@@ -202,6 +204,23 @@ private enum PendingOrder
// a non-Builder actor).
private readonly EntityId[] _builderScratch = new EntityId[SelectionManager.MaxSelectedEntities];
+ // Double-click role select (sprint 22, #50): the last own-unit click
+ // pick and its Time.time — a second pick of the SAME entity within
+ // _doubleClickSeconds reads as the double-click gesture. The scratch
+ // gathers the visible same-role ids before they replace (or, with
+ // Shift, join) the selection, so the gesture stays allocation-free.
+ private float _lastPickTime = -1f;
+ private EntityId _lastPickedEntity = EntityId.Invalid;
+ private readonly EntityId[] _roleSelectScratch = new EntityId[SelectionManager.MaxSelectedEntities];
+
+ // Idle-Builder tour (sprint 22, #50, I key): the entity index the
+ // last press returned, so the next press continues the ascending
+ // round. The scratch collects the sites' assigned-Builder raws for
+ // IdleBuilderQuery (sized to the site format capacity, so the
+ // collection can never truncate).
+ private int _lastIdleBuilderIndex = -1;
+ private readonly uint[] _assignedBuilderScratch = new uint[ConstructionSystem.MaxSites];
+
// Harvester escort cadence (D-085-pattern client dispatch): how often
// the standing harvest/return orders are re-checked against the
// economy's reach rules. Moves go through the plain command path; no
@@ -346,6 +365,30 @@ public void RequestRepairOrder()
ArmOrderPick(PendingOrder.Repair, "Repair");
}
+ ///
+ /// The command card's breakdown-row click (sprint 22, #50): reduces
+ /// the selection to that row's role — the card then redraws with
+ /// exactly this type's commands, which is the cheapest path from
+ /// "I see what is selected" to "I can work with it". Refused while a
+ /// gesture is armed (placement ghost or order pick): the input
+ /// discipline forbids selection changes mid-gesture.
+ ///
+ public void FilterSelectionToRole(UnitRole role)
+ {
+ if (!EnsureDispatcher()) return;
+ if (_placementMode || _pendingOrder != PendingOrder.None)
+ {
+ _lastCommandStatus = "Type filter: resolve or cancel the armed gesture first";
+ return;
+ }
+
+ int kept = _selection.RetainRole(_runner.Entities, role);
+ _lastCommandStatus = kept > 0
+ ? $"Type filter: {kept} unit(s) of role {role} selected"
+ : $"Type filter: nothing of role {role} left — selection cleared";
+ if (kept > 0) AudioServiceLocator.Play2D(SoundEventId.UI_Select);
+ }
+
/// Stop for the mobile selection — the S key and the card button share this path.
public void OrderStop()
{
@@ -452,7 +495,8 @@ private void Awake()
$"Units: Q {_unitDefId} | Shift+Q {_altUnitDefId} | U {_builderDefId} | N {_antiArmorDefId} | " +
$"E {_scoutDefId} | Shift+E {_lightTankDefId} | D {_battleTankDefId} | Shift+D {_artilleryDefId}\n" +
"Command card (bottom right): LMB an order button, then LMB its target in the world (RMB/ESC cancels the pick)\n" +
- "Groups: Ctrl+1..9 save selection, 1..9 recall | Shift+LMB/drag adds to the selection\n" +
+ "Groups: Ctrl+1..9 save selection, 1..9 recall | Shift+LMB/drag adds to the selection | " +
+ "double-click a unit: select all visible of its type | I: select next idle Builder, camera follows (press again to cycle)\n" +
"Camera: arrow keys / screen edge pan | wheel zoom | Z,X rotate | MMB drag rotate | Space reset rotation\n" +
"Linksklick auf ein Vorkommen: Restbestand anzeigen";
}
@@ -533,6 +577,12 @@ private bool EnsureDispatcher()
_placementMode = false;
_pendingOrder = PendingOrder.None;
_dragActive = false;
+ // The selection gestures' memory belongs to the OLD match too:
+ // an idle-Builder tour would keep cycling stale indices, and a
+ // click pair started before the rebind must not complete as a
+ // double-click in the new match.
+ _lastIdleBuilderIndex = -1;
+ _lastPickedEntity = EntityId.Invalid;
return true;
}
@@ -991,6 +1041,11 @@ private void HandleOrders(Vector2 mouse)
if (Input.GetKeyDown(KeyCode.R)) OrderReturnCargo();
+ // Sprint 22 (#50): jump to the next idle Builder — the beta
+ // report's build-flow break in its most direct form (select +
+ // camera centre, repeated presses cycle).
+ if (Input.GetKeyDown(KeyCode.I)) SelectNextIdleBuilder();
+
// Pause/resume no longer lives here: ESC/P and the pause menu
// belong to PauseMenuHud, which pauses the kernel clock directly
// (no command involved). While its modal is up, the gate in
@@ -1425,7 +1480,7 @@ private void SelectBox(Vector2 a, Vector2 b, bool additive)
_lastCommandStatus = additive ? "Box select (added): 0 unit(s) selected" : "Box select: 0 unit(s)";
}
- /// Click select: nearest own active unit within , else — non-additive only — the field under the cursor within for its reserve readout (21.2, #86); additive with Shift, else replace (a plain click on empty ground clears).
+ /// Click select: nearest own active unit within , else — non-additive only — the field under the cursor within for its reserve readout (21.2, #86); additive with Shift, else replace (a plain click on empty ground clears). A second click on the SAME unit within is the double-click gesture (sprint 22, #50): all own units of its role visible in the camera image.
private void SelectSingle(Vector2 screenPoint, bool additive)
{
if (_runner.Entities == null) return;
@@ -1433,6 +1488,20 @@ private void SelectSingle(Vector2 screenPoint, bool additive)
{
if (TryPickUnit(world, ownedByLocalSlot: true, out EntityId picked))
{
+ // The double-click check runs BEFORE the record updates:
+ // a pair is the same entity twice within the window.
+ bool doubleClick = picked == _lastPickedEntity
+ && Time.time - _lastPickTime <= _doubleClickSeconds;
+ _lastPickedEntity = picked;
+ _lastPickTime = Time.time;
+
+ if (doubleClick
+ && _runner.Entities.TryGetUnit(picked, out UnitState pickedUnit))
+ {
+ SelectVisibleSameRole(in pickedUnit, additive);
+ return;
+ }
+
if (additive)
{
bool added = _selection.AddSingle(picked);
@@ -1448,6 +1517,12 @@ private void SelectSingle(Vector2 screenPoint, bool additive)
return;
}
+ // No unit under the cursor: any in-flight click pair breaks
+ // here, so the next unit pick starts a fresh pair instead of
+ // completing a stale double-click across an empty-ground
+ // click.
+ _lastPickedEntity = EntityId.Invalid;
+
// The field pick sits BEHIND the unit pick: a harvester
// standing on its field stays selectable. Additive clicks
// never pick a field — a field cannot join a unit selection.
@@ -1467,6 +1542,96 @@ private void SelectSingle(Vector2 screenPoint, bool additive)
}
}
+ ///
+ /// The double-click gesture (sprint 22, #50): selects every own unit
+ /// of the double-clicked unit's role whose centre projects into the
+ /// CURRENT camera image — the RTS convention, bounded to the
+ /// viewport on purpose: a map-wide select would be a different
+ /// command and would surprise the player. Shift keeps the gesture
+ /// additive, the same discipline Shift-click and Shift-drag already
+ /// follow (sprint 09 §7). The clicked unit anchors the result even
+ /// if its own centre sits a hair outside the viewport (the pick
+ /// radius reaches past the screen edge), so the gesture can never
+ /// read as "clear selection".
+ ///
+ private void SelectVisibleSameRole(in UnitState clicked, bool additive)
+ {
+ EntityManager entities = _runner.Entities;
+ byte slot = _dispatcher.LocalSlot;
+ UnitState[] units = entities.RawUnits;
+ int capacity = entities.Capacity;
+
+ int gathered = 0;
+ _roleSelectScratch[gathered++] = clicked.Id; // the anchor, always
+ for (int i = 0; i < capacity && gathered < _roleSelectScratch.Length; i++)
+ {
+ ref readonly UnitState unit = ref units[i];
+ if (!unit.IsActive || unit.PlayerId != slot || unit.Role != clicked.Role) continue;
+ if (unit.Id == clicked.Id) continue; // already anchored
+ if (!IsCentreOnScreen(in unit)) continue;
+ _roleSelectScratch[gathered++] = unit.Id;
+ }
+
+ int total = additive
+ ? _selection.AddRange(_roleSelectScratch.AsSpan(0, gathered))
+ : _selection.ReplaceSelection(_roleSelectScratch.AsSpan(0, gathered));
+ _lastCommandStatus = additive
+ ? $"Double-click (added): {total} unit(s) of role {clicked.Role} selected"
+ : $"Double-click: {total} visible unit(s) of role {clicked.Role}";
+ AudioServiceLocator.Play2D(SoundEventId.UI_Select);
+ }
+
+ /// True when the unit's centre projects into the current camera image — presentation-side visibility for the double-click gesture (floats are this layer's currency).
+ private bool IsCentreOnScreen(in UnitState unit)
+ {
+ if (_camera == null) _camera = Camera.main;
+ if (_camera == null) return false;
+
+ Vector3 screen = _camera.WorldToScreenPoint(new Vector3(
+ unit.Transform.PositionX.ToFloat(), _groundPlaneY, unit.Transform.PositionY.ToFloat()));
+ return screen.z > 0f
+ && screen.x >= 0f && screen.x <= Screen.width
+ && screen.y >= 0f && screen.y <= Screen.height;
+ }
+
+ ///
+ /// The I key (sprint 22, #50 — the beta report's build-flow break:
+ /// the player lost the Builder in a clump and could not build):
+ /// selects the next IDLE own Builder and centres the camera on him;
+ /// repeated presses tour all idle Builders in ascending entity
+ /// order. owns the idle definition
+ /// (with its documented repair blind spot) and the deterministic
+ /// cycle. The camera jump travels the minimap's focus channel — this
+ /// assembly may not reference the camera rig (same rank) — and is
+ /// pure presentation: no command, no simulation read, nothing a
+ /// snapshot sees.
+ ///
+ private void SelectNextIdleBuilder()
+ {
+ if (_runner.Entities == null || _runner.Construction == null) return;
+
+ if (!IdleBuilderQuery.TryFindNextIdleBuilder(
+ _runner.Entities, _runner.Construction, _dispatcher.LocalSlot,
+ _lastIdleBuilderIndex, _assignedBuilderScratch, out EntityId builder))
+ {
+ // No idle Builder: restart the tour at the bottom on the next
+ // press, so a Builder finishing his job is found immediately.
+ _lastIdleBuilderIndex = -1;
+ _lastCommandStatus = "Idle Builder: none — every Builder is working (or none exists)";
+ return;
+ }
+
+ _selection.SelectSingle(builder);
+ _lastIdleBuilderIndex = builder.Index;
+ if (_runner.Entities.TryGetUnit(builder, out UnitState state))
+ {
+ MinimapCameraLink.RequestFocus(
+ state.Transform.PositionX.ToFloat(), state.Transform.PositionY.ToFloat());
+ }
+ _lastCommandStatus = $"Idle Builder: entity {builder.Index} selected — I cycles";
+ AudioServiceLocator.Play2D(SoundEventId.UI_Select);
+ }
+
/// Nearest active unit to a ground point, filtered by ownership.
private bool TryPickUnit(Vector3 world, bool ownedByLocalSlot, out EntityId picked)
{
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a397c5f..881afd5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -73,6 +73,22 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
spielerisch abgenommen und kein Meilenstein-Nachweis
### Hinzugefügt
+- **Die Auswahl ist benutzbar geworden (#50).** Aus dem Betatest: „Weil ich den
+ Pionier in der Gruppe nicht wiederfand, konnte ich nicht bauen." Paket 21.5
+ hatte die Aufstellung geliefert, aber man konnte sie nicht anfassen. Jetzt
+ **filtert ein Klick auf eine Typzeile** die Auswahl auf genau diese Einheiten
+ (unter Erhalt der Auswahlreihenfolge, tote Handles fallen raus), ein
+ **Doppelklick** wählt alle Einheiten derselben Rolle *im Kamerabild* (Shift
+ bleibt additiv), und **`I`** springt zum nächsten unbeschäftigten Pionier und
+ zentriert die Kamera auf ihn — ohne das Zentrieren fände man ihn genauso wenig
+ wie vorher. Wiederholtes Drücken tourt in aufsteigender Entitäts-Reihenfolge
+ und läuft einmal um. Was „unbeschäftigt" heißt, ist am Code abgelesen und
+ nicht geraten: die fünf Marker sind die Räumliste des `Stop`-Befehls plus die
+ Baustellenzuweisung. Ein sechster — ein laufender Reparaturauftrag — ist aus
+ der Auswahlschicht nicht sichtbar und ist als bewusster, dokumentierter
+ blinder Fleck im Code vermerkt, statt genähert zu werden; ihn zu schließen
+ bräuchte einen Leser in `Simulation/**` und gehört nicht zu #50. Reine
+ Auswahl und Darstellung, kein Simulationseingriff
- **Tragzeit des Startfelds ist gemessen, nicht geraten (Paket 21.3, #87).**
`tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs` fährt den echten
Ernte-Auto-Zyklus auf der kanonischen Eröffnungsgeometrie (Feld (7,7) mit