diff --git a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs
index 8d82d9f..4320fa4 100644
--- a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs
+++ b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs
@@ -378,5 +378,34 @@ public void DisplayNames_MatchTheMs1NameTables()
// Fallback for roles outside the named roster stays the enum name.
Assert.AreEqual(UnitRole.Unit.ToString(), CommandCardPresenter.UnitDisplayName(FactionId.Alliance, UnitRole.Unit));
}
+
+ // ----------------------------------------------------------------
+ // Field reserve line (21.2, #86)
+ // ----------------------------------------------------------------
+
+ [Test]
+ public void FormatFieldReserveAE_GroupsThousandsGermanStyle()
+ {
+ // The report's own example from sprint package 21.2.
+ Assert.AreEqual("6.420 / 9.000 AE", CommandCardPresenter.FormatFieldReserveAE(6420, 9000));
+ Assert.AreEqual("0 / 15.000 AE", CommandCardPresenter.FormatFieldReserveAE(0, 15000));
+ Assert.AreEqual("12.345.678 / 12.345.678 AE", CommandCardPresenter.FormatFieldReserveAE(12345678, 12345678));
+ }
+
+ [Test]
+ public void FormatFieldReserveAE_SmallValuesStayUngrouped()
+ {
+ Assert.AreEqual("642 / 900 AE", CommandCardPresenter.FormatFieldReserveAE(642, 900));
+ Assert.AreEqual("1 / 1 AE", CommandCardPresenter.FormatFieldReserveAE(1, 1));
+ }
+
+ [Test]
+ public void FormatFieldReserveAE_NonPositiveValuesRenderAsZero()
+ {
+ // Reserve values are never negative in the sim; a hostile input
+ // must still render sanely instead of producing "-6.420".
+ Assert.AreEqual("0 / 0 AE", CommandCardPresenter.FormatFieldReserveAE(0, 0));
+ Assert.AreEqual("0 / 9.000 AE", CommandCardPresenter.FormatFieldReserveAE(-5, 9000));
+ }
}
}
diff --git a/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs b/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs
new file mode 100644
index 0000000..01f4f5d
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs
@@ -0,0 +1,71 @@
+using NUnit.Framework;
+using Nova.Gameplay;
+
+namespace Nova.Gameplay.Tests
+{
+ ///
+ /// Contract tests for the field-marker staging rule of
+ /// (21.2, #86): ceiling of the reserve
+ /// fraction times the shard count — full reserve lights every shard, any
+ /// reserve above zero keeps at least one, exactly 0 AE lights none, and
+ /// the result never leaves [0, shardCount].
+ ///
+ [TestFixture]
+ public class FieldCrystalStagesTests
+ {
+ [Test]
+ public void VisibleShards_FullReserve_ShowsEveryShard()
+ {
+ Assert.AreEqual(7, FieldCrystalStages.VisibleShards(9000, 9000, 7));
+ Assert.AreEqual(7, FieldCrystalStages.VisibleShards(15000, 15000, 7));
+ }
+
+ [Test]
+ public void VisibleShards_ZeroRemaining_ShowsNone()
+ {
+ Assert.AreEqual(0, FieldCrystalStages.VisibleShards(0, 9000, 7));
+ }
+
+ [Test]
+ public void VisibleShards_StageBoundaries_RoundUp()
+ {
+ // shardCount 4 over 8.000 AE: stage k holds while the reserve is
+ // in ((k-1)/4, k/4] of the initial reserve — the boundary value
+ // itself still shows the HIGHER stage's lower edge exactly.
+ Assert.AreEqual(4, FieldCrystalStages.VisibleShards(8000, 8000, 4));
+ Assert.AreEqual(4, FieldCrystalStages.VisibleShards(6001, 8000, 4));
+ Assert.AreEqual(3, FieldCrystalStages.VisibleShards(6000, 8000, 4));
+ Assert.AreEqual(3, FieldCrystalStages.VisibleShards(4001, 8000, 4));
+ Assert.AreEqual(2, FieldCrystalStages.VisibleShards(4000, 8000, 4));
+ Assert.AreEqual(1, FieldCrystalStages.VisibleShards(2000, 8000, 4));
+ Assert.AreEqual(1, FieldCrystalStages.VisibleShards(1, 8000, 4), "any reserve above zero keeps one shard");
+ Assert.AreEqual(0, FieldCrystalStages.VisibleShards(0, 8000, 4), "0 AE means none — the stump is the view's business");
+ }
+
+ [Test]
+ public void VisibleShards_IsMonotonicallyNonIncreasing()
+ {
+ int previous = FieldCrystalStages.VisibleShards(9000, 9000, 7);
+ for (long remaining = 8999; remaining >= 0; remaining -= 97)
+ {
+ int stage = FieldCrystalStages.VisibleShards(remaining, 9000, 7);
+ Assert.LessOrEqual(stage, previous, $"stage must not rise as the reserve falls (remaining {remaining})");
+ Assert.GreaterOrEqual(stage, 0);
+ Assert.LessOrEqual(stage, 7);
+ previous = stage;
+ }
+ Assert.AreEqual(0, FieldCrystalStages.VisibleShards(0, 9000, 7), "the walk ends at the exhausted stage");
+ }
+
+ [Test]
+ public void VisibleShards_Guards_ClampIntoRange()
+ {
+ // Over-reserve (or a layout/console slip) can never light more
+ // shards than the cluster has; degenerate inputs show nothing.
+ Assert.AreEqual(7, FieldCrystalStages.VisibleShards(20000, 9000, 7));
+ Assert.AreEqual(0, FieldCrystalStages.VisibleShards(100, 0, 7), "unknown initial reserve shows nothing rather than dividing by zero");
+ Assert.AreEqual(0, FieldCrystalStages.VisibleShards(-5, 9000, 7));
+ Assert.AreEqual(0, FieldCrystalStages.VisibleShards(100, 100, 0), "an empty cluster has no shards to light");
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs.meta b/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs.meta
new file mode 100644
index 0000000..07525ec
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 69d1addf551c455787f5b89c21a2f40f
diff --git a/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs b/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs
index e794fe4..c04a74d 100644
--- a/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs
+++ b/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs
@@ -152,5 +152,66 @@ public void SelectionManager_RecallEmptyGroup_IsANoOp()
Assert.IsFalse(selection.HasControlGroup(5));
Assert.AreEqual(0, selection.RecallControlGroup(5, entities, playerId: 0));
}
+
+ // ------------------------------------------------------------------
+ // Sprint 21.2 (#86): field selection — UI-only, coupled both ways
+ // ------------------------------------------------------------------
+
+ [Test]
+ public void SelectionManager_SelectField_ClearsEntitySelection()
+ {
+ 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));
+ selection.SelectSingle(u1);
+ selection.AddSingle(u2);
+
+ selection.SelectField(3);
+
+ Assert.AreEqual(0, selection.SelectedCount, "a field takes no entity orders — the entity selection goes");
+ Assert.AreEqual((ushort)3, selection.SelectedFieldId);
+ }
+
+ [Test]
+ public void SelectionManager_EntitySelection_ClearsSelectedField()
+ {
+ 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));
+
+ selection.SelectField(2);
+ selection.SelectSingle(u1);
+ Assert.AreEqual((ushort)0, selection.SelectedFieldId, "SelectSingle replaces the field");
+ Assert.AreEqual(1, selection.SelectedCount);
+
+ selection.SelectField(2);
+ selection.AddSingle(u2);
+ Assert.AreEqual((ushort)0, selection.SelectedFieldId, "an additive entity pick ends the field selection too");
+ Assert.AreEqual(1, selection.SelectedCount);
+
+ selection.SelectField(2);
+ selection.SelectBox(entities, playerId: 0, minX: 0f, minY: 0f, maxX: 20f, maxY: 20f);
+ Assert.AreEqual((ushort)0, selection.SelectedFieldId, "a box selection replaces the field");
+ Assert.AreEqual(2, selection.SelectedCount);
+ }
+
+ [Test]
+ public void SelectionManager_ClearSelection_ClearsFieldAndEntities()
+ {
+ 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));
+ selection.SelectSingle(u1);
+
+ selection.ClearSelection();
+ Assert.AreEqual(0, selection.SelectedCount);
+ Assert.AreEqual((ushort)0, selection.SelectedFieldId);
+
+ selection.SelectField(5);
+ selection.ClearSelection();
+ Assert.AreEqual((ushort)0, selection.SelectedFieldId, "the ingress rebind relies on ClearSelection dropping the field too");
+ }
}
}
diff --git a/Assets/Tests/PlayMode/FieldReservePickTests.cs b/Assets/Tests/PlayMode/FieldReservePickTests.cs
new file mode 100644
index 0000000..1841222
--- /dev/null
+++ b/Assets/Tests/PlayMode/FieldReservePickTests.cs
@@ -0,0 +1,124 @@
+using System.Collections;
+using System.Reflection;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using UnityEngine.TestTools;
+using Nova.Gameplay;
+using Nova.Gameplay.Match;
+
+namespace Nova.PlayMode.Tests
+{
+ ///
+ /// Regression/diagnosis for the 21.2 play-observation finding "fields are
+ /// not clickable" (#86): drives the real click path of RtsDeviceInput
+ /// (private SelectSingle/TryPickUnit/TryPickField via reflection — test
+ /// assemblies may not reference Nova.Presentation.UI, the MainMenuTests
+ /// pattern) with the field centre projected through the real main camera,
+ /// and logs every stage's verdict so a failure names its stage.
+ /// Run headless-with-graphics and NEVER with -quit (see
+ /// GrayboxDemoProofTests' header for the invocation).
+ ///
+ public sealed class FieldReservePickTests
+ {
+ private const string ScenePath = "Assets/_Project/Scenes/Bootstrap.unity";
+
+ [UnityTest]
+ public IEnumerator ClickOnStartField_SelectsTheField()
+ {
+ yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single);
+
+ var bootstrap = Object.FindAnyObjectByType();
+ Assert.NotNull(bootstrap, "Bootstrap scene contains no MatchBootstrap");
+ bootstrap.StartGrayboxMatch();
+ Assert.IsTrue(bootstrap.IsMatchReady, "match did not start");
+
+ // Two frames: let every Awake/Start and the first model rebuilds settle.
+ yield return null;
+ yield return null;
+
+ MonoBehaviour input = FindByTypeName("RtsDeviceInput");
+ Assert.NotNull(input, "scene contains no RtsDeviceInput");
+
+ // The serialized scene predates the 21.2 field: a missing YAML
+ // entry must materialise the C# default 2f — pin that assumption,
+ // it is exactly the kind of silent zero a scene upgrade swallows.
+ FieldInfo radiusField = input.GetType().GetField(
+ "_fieldPickRadiusWorld", BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.NotNull(radiusField, "RtsDeviceInput._fieldPickRadiusWorld missing");
+ float radius = (float)radiusField.GetValue(input);
+ Debug.Log($"[FieldPick] _fieldPickRadiusWorld = {radius}");
+ Assert.Greater(radius, 0.5f, "field pick radius deserialised as ~0 — old scene asset ate the default");
+
+ Camera camera = Camera.main;
+ Assert.NotNull(camera, "no main camera");
+
+ // Stage 1: the raw field probe at the field centre of the
+ // canonical start field (7,7) -> centre (7.5, 0, 7.5).
+ var world = new Vector3(7.5f, 0f, 7.5f);
+ object[] pickArgs = { world, (ushort)0 };
+ bool fieldHit = (bool)InvokePrivate(input, "TryPickField", pickArgs);
+ Debug.Log($"[FieldPick] TryPickField({world}) = {fieldHit}, id = {pickArgs[1]}");
+
+ // Stage 2: does a UNIT claim the same point first (the intended
+ // priority — but then the click reads as unit selection)?
+ object[] unitArgs = { world, true, Nova.Core.EntityId.Invalid };
+ bool unitHit = (bool)InvokePrivate(input, "TryPickUnit", unitArgs);
+ Debug.Log($"[FieldPick] TryPickUnit({world}, own) = {unitHit}, id = {unitArgs[2]}");
+
+ // Stage 3: the real click path with the camera-projected point.
+ Vector3 screen = camera.WorldToScreenPoint(world);
+ Debug.Log($"[FieldPick] field centre on screen = {screen} (screen {Screen.width}x{Screen.height})");
+ InvokePrivate(input, "SelectSingle", new object[] { new Vector2(screen.x, screen.y), false });
+
+ var selection = (SelectionManager)input.GetType().GetProperty("Selection").GetValue(input);
+ Debug.Log($"[FieldPick] after SelectSingle: SelectedFieldId = {selection.SelectedFieldId}, " +
+ $"SelectedCount = {selection.SelectedCount}");
+
+ Assert.IsTrue(fieldHit, "TryPickField rejected the field centre itself");
+ Assert.AreEqual((ushort)1, selection.SelectedFieldId,
+ "a click on the start field must select field #1 (21.2, #86)");
+ Assert.AreEqual(0, selection.SelectedCount, "a field selection owns no entities");
+
+ // The real play-observation failure (T-02): on a trackpad a
+ // "click" is a MICRO-DRAG past the 8 px threshold, which becomes
+ // a box — and the box held no entities, so the gesture read as
+ // "clear selection". A unit forgives the same gesture (the box
+ // catches it), a field did not. Reproduce that exact gesture:
+ // a small, unit-empty drag across the field must select it too.
+ selection.ClearSelection();
+ // A tight quadrant of +10 px around the field-centre pixel:
+ // provably unit-empty here (stage 2 found no own unit within
+ // the wider 1.5-cell pick radius), so the box exercises the
+ // empty-gesture path and nothing else.
+ InvokePrivate(input, "SelectBox", new object[]
+ {
+ new Vector2(screen.x, screen.y),
+ new Vector2(screen.x + 10f, screen.y + 10f),
+ false,
+ });
+ Debug.Log($"[FieldPick] after micro-drag SelectBox: SelectedFieldId = {selection.SelectedFieldId}, " +
+ $"SelectedCount = {selection.SelectedCount}");
+ Assert.AreEqual((ushort)1, selection.SelectedFieldId,
+ "a micro-drag over the start field (no units inside the box) must select field #1, not clear into nothing (21.2 play finding)");
+ }
+
+ private static object InvokePrivate(MonoBehaviour target, string method, object[] args)
+ {
+ MethodInfo info = target.GetType().GetMethod(method, BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.NotNull(info, $"{target.GetType().Name}.{method} not found");
+ object result = info.Invoke(target, args);
+ return result;
+ }
+
+ private static MonoBehaviour FindByTypeName(string typeName)
+ {
+ MonoBehaviour[] all = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None);
+ for (int i = 0; i < all.Length; i++)
+ {
+ if (all[i] != null && all[i].GetType().Name == typeName) return all[i];
+ }
+ return null;
+ }
+ }
+}
diff --git a/Assets/Tests/PlayMode/FieldReservePickTests.cs.meta b/Assets/Tests/PlayMode/FieldReservePickTests.cs.meta
new file mode 100644
index 0000000..b847689
--- /dev/null
+++ b/Assets/Tests/PlayMode/FieldReservePickTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 058f58a4d84a24eb6aac7241f3cf2a16
\ No newline at end of file
diff --git a/Assets/_Project/Editor/BootstrapSceneGenerator.cs b/Assets/_Project/Editor/BootstrapSceneGenerator.cs
index 693e88c..fd0c639 100644
--- a/Assets/_Project/Editor/BootstrapSceneGenerator.cs
+++ b/Assets/_Project/Editor/BootstrapSceneGenerator.cs
@@ -345,6 +345,7 @@ private static GameObject CreateUiObject(MatchRunner runner, Camera camera)
WireReference(card, "_runner", runner);
WireReference(card, "_input", input);
WireReference(card, "_buildMenu", menu);
+ WireReference(card, "_bootstrap", runner.GetComponent());
WireReference(input, "_commandCard", card);
diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs
index 363b599..cf29520 100644
--- a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs
+++ b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs
@@ -247,6 +247,27 @@ private struct FieldLayout
public ushort LocalFieldId => LocalPlayerLayout.FieldId;
public ushort EnemyFieldId => EnemyPlayerLayout.FieldId;
+ ///
+ /// The initial reserve of a canonical field (21.2, #86). It lives in
+ /// the canonical map layout, NOT in simulation state —
+ /// AetheriumField deliberately carries no InitialReserve field
+ /// because the Simulation/State/ layout is frozen. Returns false for
+ /// an unknown id.
+ ///
+ public bool TryGetFieldInitialReserve(ushort fieldId, out long reserveAE)
+ {
+ for (int i = 0; i < FieldLayouts.Length; i++)
+ {
+ if (FieldLayouts[i].Id == fieldId)
+ {
+ reserveAE = FieldLayouts[i].ReserveAE;
+ return true;
+ }
+ }
+ reserveAE = 0L;
+ return false;
+ }
+
/// Aetherium field cell of the human player (7, 7).
public Vector2Int LocalFieldCell => new Vector2Int(LocalPlayerLayout.FieldX, LocalPlayerLayout.FieldY);
diff --git a/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs b/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs
index 335f5f5..54fbe4f 100644
--- a/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs
+++ b/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs
@@ -1,4 +1,5 @@
using System;
+using System.Text;
using Nova.Core;
using Nova.Simulation.Construction;
using Nova.Simulation.Definitions;
@@ -309,6 +310,42 @@ public static bool TryFindRepairBuilder(EntityManager entities, byte playerSlot,
return false;
}
+ ///
+ /// The field card's reserve line (21.2, #86): "6.420 / 9.000 AE" —
+ /// German thousands grouping, assembled digit by digit so the output
+ /// is identical under ANY ambient culture (a build on an en-US host
+ /// must not render "6,420").
+ ///
+ public static string FormatFieldReserveAE(long remainingAE, long initialReserveAE)
+ {
+ var builder = new StringBuilder(24);
+ AppendGroupedDe(builder, remainingAE);
+ builder.Append(" / ");
+ AppendGroupedDe(builder, initialReserveAE);
+ builder.Append(" AE");
+ return builder.ToString();
+ }
+
+ /// Decimal digits with the German '.' group separator; reserve values are never negative, so a non-positive input renders as "0".
+ private static void AppendGroupedDe(StringBuilder builder, long value)
+ {
+ if (value <= 0)
+ {
+ builder.Append('0');
+ return;
+ }
+
+ int digitCount = 1;
+ for (long rest = value; rest >= 10; rest /= 10) digitCount++;
+ for (int i = 0; i < digitCount; i++)
+ {
+ if (i > 0 && (digitCount - i) % 3 == 0) builder.Append('.');
+ long divisor = 1;
+ for (int d = 1; d < digitCount - i; d++) divisor *= 10;
+ builder.Append((char)('0' + (int)(value / divisor % 10)));
+ }
+ }
+
///
/// German display names of the eight MS-1 unit roles per faction,
/// from the canonical MS-1 name tables of docs/gamedesign/Vehicles.md
diff --git a/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs b/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs
new file mode 100644
index 0000000..0c3fd67
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs
@@ -0,0 +1,42 @@
+namespace Nova.Gameplay
+{
+ ///
+ /// The crystal-stage rule of an Aetherium field marker (21.2, #86): how
+ /// many of a cluster's shards stay visible at a given remaining reserve.
+ /// Pure presentation math over read-only economy values — no simulation
+ /// contact, no UnityEngine dependency, so EditMode tests cover the whole
+ /// staging.
+ ///
+ /// STAGING RULE: the visible shard count is the reserve fraction mapped
+ /// onto the cluster, rounded UP (ceiling). A full field shows every
+ /// shard; any reserve above zero keeps at least one shard, so a field
+ /// being worked never reads as empty; exactly 0 AE
+ /// (AetheriumField.IsExhausted) shows none. The exhausted LOOK —
+ /// one flattened, darkened stump instead of bare ground — is the view's
+ /// own dressing on top of stage 0 and not this function's concern. The
+ /// result is clamped into [0, shardCount], so an over-reserve reading
+ /// (or an unknown initial reserve) can never light more shards than the
+ /// cluster has.
+ ///
+ ///
+ public static class FieldCrystalStages
+ {
+ ///
+ /// Shards lit at out of
+ /// for a cluster of
+ /// : ceiling of the reserve fraction
+ /// times the shard count (see the class remarks for the exact
+ /// staging rule).
+ ///
+ public static int VisibleShards(long remainingAE, long initialReserveAE, int shardCount)
+ {
+ if (shardCount <= 0) return 0;
+ if (remainingAE <= 0 || initialReserveAE <= 0) return 0;
+ if (remainingAE >= initialReserveAE) return shardCount;
+
+ long visible = (remainingAE * shardCount + initialReserveAE - 1) / initialReserveAE;
+ if (visible < 1) return 1;
+ return visible > shardCount ? shardCount : (int)visible;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs.meta b/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs.meta
new file mode 100644
index 0000000..8c9176d
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 509aa27c3f904909ad2f96767b1e3d8f
diff --git a/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs b/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs
index b6e9ddc..10363ae 100644
--- a/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs
+++ b/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs
@@ -28,6 +28,13 @@ public sealed class SelectionManager
public int SelectedCount => _selectedCount;
public ReadOnlySpan SelectedEntities => _selectedIds.AsSpan(0, _selectedCount);
+ ///
+ /// The selected Aetherium field (21.2, #86), 0 = none. Fields are not
+ /// entities, so their id lives BESIDE the entity list, never inside
+ /// it — same UI-only character, no simulation contact.
+ ///
+ public ushort SelectedFieldId { get; private set; }
+
public SelectionManager()
{
_selectedIds = new EntityId[MaxSelectedEntities];
@@ -40,6 +47,14 @@ public SelectionManager()
public void ClearSelection()
{
_selectedCount = 0;
+ SelectedFieldId = 0;
+ }
+
+ /// Selects a field for its reserve readout; clears the entity selection (a field takes no entity orders).
+ public void SelectField(ushort fieldId)
+ {
+ ClearSelection();
+ SelectedFieldId = fieldId;
}
public bool SelectSingle(EntityId id)
@@ -60,6 +75,8 @@ public bool AddSingle(EntityId id)
{
if (_selectedIds[i] == id) return false;
}
+ // An entity joining the selection ends any field selection.
+ SelectedFieldId = 0;
_selectedIds[_selectedCount++] = id;
return true;
}
diff --git a/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs b/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs
index 16b5996..af6fc6b 100644
--- a/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs
+++ b/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs
@@ -1,5 +1,8 @@
+using System.Collections.Generic;
using UnityEngine;
+using Nova.Gameplay;
using Nova.Gameplay.Match;
+using Nova.Simulation.Economy;
namespace Nova.Presentation.Maps
{
@@ -25,6 +28,16 @@ namespace Nova.Presentation.Maps
/// clone; the procedural desert is the permanent baseline a CC0 drop-in
/// may later decorate, never a prerequisite.
///
+ ///
+ /// RESERVE STAGES (21.2, #86): the crystal cluster of each field follows
+ /// its remaining reserve — the shards go out one by one as the field is
+ /// worked ( owns the staging rule), and
+ /// an exhausted field keeps a single flattened, darkened stump, so it
+ /// reads as burnt-out rather than simply empty. The readout runs on a
+ /// half-second cadence over the same read-only economy API the HUD
+ /// uses — never a per-frame simulation query — and a match restart
+ /// simply re-reads the refilled reserves and restores every shard.
+ ///
///
[DisallowMultipleComponent]
[DefaultExecutionOrder(50)] // builds after MatchBootstrap.Start (default order 0)
@@ -79,6 +92,22 @@ public sealed class GlutrinneBlockoutView : MonoBehaviour
1.10f, 0.65f, 0.80f, 0.55f, 0.70f, 0.50f, 0.60f,
};
+ /// Reserve readout cadence (21.2): the shard stages follow RemainingAE on the HUD's read rail — the harvester-escort interval of RtsDeviceInput, never a per-frame query.
+ private const float ReserveReadIntervalSeconds = 0.5f;
+
+ /// Height of the one flattened stump an exhausted field keeps (the burnt-out read instead of bare ground).
+ private const float ExhaustedStumpHeight = 0.12f;
+
+ // Shard cache per field, built once in Start: index f holds the
+ // shards of field id f + 1, because AllFieldCells iterates the
+ // canonical layout in ascending id order. A match restart does NOT
+ // rebuild the scene, so the markers survive — the cadence re-reads
+ // the fresh economy and restores the full clusters itself.
+ private readonly List _fieldShards = new List();
+ private readonly List _fieldStages = new List();
+ private float _reserveNextReadTime;
+ private MaterialPropertyBlock _propertyBlock;
+
private void Start()
{
if (_bootstrap == null) _bootstrap = FindAnyObjectByType();
@@ -95,7 +124,39 @@ private void Start()
BuildWeatheredEdge(_bootstrap.MapSize);
for (int i = 0; i < fieldCells.Length; i++)
{
- BuildFieldMarker(fieldCells[i], $"Field_{i + 1}");
+ _fieldShards.Add(BuildFieldMarker(fieldCells[i], $"Field_{i + 1}"));
+ // Full reserve until the first readout: the clusters are
+ // built lit, so nothing flickers while no economy exists yet.
+ _fieldStages.Add(ClusterOffsets.Length);
+ }
+ }
+
+ ///
+ /// The reserve readout (21.2, #86): each field's shard stage follows
+ /// its RemainingAE, applied ONLY on a stage change (the health-tint
+ /// bucket precedent of UnitViewManager — a full match costs no more
+ /// than before). No runner or no economy yet (menu, network
+ /// handshake) means no read: the markers keep their full look.
+ ///
+ private void Update()
+ {
+ if (Time.time < _reserveNextReadTime) return;
+ _reserveNextReadTime = Time.time + ReserveReadIntervalSeconds;
+
+ MatchRunner runner = _bootstrap != null ? _bootstrap.Runner : null;
+ EconomySystem economy = runner != null ? runner.Economy : null;
+ if (economy == null) return;
+
+ for (int f = 0; f < _fieldShards.Count; f++)
+ {
+ ushort fieldId = (ushort)(f + 1);
+ if (!economy.TryGetField(fieldId, out AetheriumField field)) continue;
+ if (!_bootstrap.TryGetFieldInitialReserve(fieldId, out long initialReserveAE)) continue;
+
+ int stage = FieldCrystalStages.VisibleShards(field.RemainingAE, initialReserveAE, _fieldShards[f].Length);
+ if (stage == _fieldStages[f]) continue;
+ _fieldStages[f] = stage;
+ ApplyFieldStage(_fieldShards[f], stage, field.IsExhausted);
}
}
@@ -255,13 +316,14 @@ private static float Next01(ref uint state)
}
}
- private void BuildFieldMarker(Vector2Int cell, string label)
+ private GameObject[] BuildFieldMarker(Vector2Int cell, string label)
{
var marker = new GameObject($"AetheriumField_{label}_{cell.x}_{cell.y}");
marker.transform.SetParent(transform, false);
marker.transform.position = new Vector3(cell.x + 0.5f, 0f, cell.y + 0.5f);
Material crystalMaterial = CreateRuntimeMaterial(_crystalColor);
+ var shards = new GameObject[ClusterOffsets.Length];
for (int i = 0; i < ClusterOffsets.Length; i++)
{
float height = ClusterHeights[i];
@@ -275,7 +337,64 @@ private void BuildFieldMarker(Vector2Int cell, string label)
// Pure marker: no collider, so nothing can ever pick or block a crystal.
Destroy(shard.GetComponent());
shard.GetComponent().sharedMaterial = crystalMaterial;
+ shards[i] = shard;
}
+ return shards;
+ }
+
+ ///
+ /// Applies one reserve stage: the first
+ /// shards stand lit, the rest go dark — except on an exhausted field,
+ /// where shard 0 survives as the flattened, darkened stump that reads
+ /// "burnt-out" instead of "there was never anything here".
+ ///
+ private void ApplyFieldStage(GameObject[] shards, int stage, bool exhausted)
+ {
+ for (int i = 0; i < shards.Length; i++)
+ {
+ if (i < stage)
+ {
+ RestoreShard(shards[i], i);
+ }
+ else if (exhausted && i == 0)
+ {
+ ApplyExhaustedStump(shards[i]);
+ }
+ else
+ {
+ shards[i].SetActive(false);
+ }
+ }
+ }
+
+ /// The lit look of one shard: its literal cluster shape — and no property block, dropping any exhausted darkening (a match restart refills every reserve).
+ private void RestoreShard(GameObject shard, int clusterIndex)
+ {
+ float height = ClusterHeights[clusterIndex];
+ shard.transform.localPosition = new Vector3(ClusterOffsets[clusterIndex].x, height * 0.5f, ClusterOffsets[clusterIndex].y);
+ shard.transform.localScale = new Vector3(0.35f, height, 0.35f);
+ shard.GetComponent().SetPropertyBlock(null);
+ shard.SetActive(true);
+ }
+
+ ///
+ /// The exhausted stump: shard 0 flattened and darkened to a third of
+ /// the crystal tone via MaterialPropertyBlock (both colour
+ /// properties, the FactionTint idiom) — the shared cluster material
+ /// itself stays untouched, or every field would darken together.
+ ///
+ private void ApplyExhaustedStump(GameObject shard)
+ {
+ shard.transform.localPosition = new Vector3(ClusterOffsets[0].x, ExhaustedStumpHeight * 0.5f, ClusterOffsets[0].y);
+ shard.transform.localScale = new Vector3(0.45f, ExhaustedStumpHeight, 0.45f);
+
+ if (_propertyBlock == null) _propertyBlock = new MaterialPropertyBlock();
+ _propertyBlock.Clear();
+ Color darkened = _crystalColor * 0.30f;
+ darkened.a = 1f;
+ FactionTint.ApplyToPropertyBlock(_propertyBlock, darkened);
+ shard.GetComponent().SetPropertyBlock(_propertyBlock);
+ shard.SetActive(true);
}
}
}
diff --git a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs
index 1b92529..7c04ecb 100644
--- a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs
+++ b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs
@@ -7,6 +7,7 @@
using Nova.Gameplay.Match;
using Nova.Simulation.Construction;
using Nova.Simulation.Definitions;
+using Nova.Simulation.Economy;
using Nova.Simulation.Production;
using Nova.Simulation.State;
using EntityId = Nova.Core.EntityId;
@@ -36,6 +37,10 @@ namespace Nova.Presentation.UI
/// shown with the head entry's progress (Q16.16 ticks against
/// BuildTicks<<16) and a per-entry cancel. A construction site gets
/// its progress and CancelConstruction (75% refund).
+ /// A selected Aetherium FIELD (21.2, #86) gets no buttons at all — just
+ /// its title plus one row, the remaining reserve against the canonical
+ /// initial reserve from the map layout ("6.420 / 9.000 AE", exhausted
+ /// fields named in the title).
///
///
/// REPAIR FLOW (decided, GB-006): the sim issues repair as a BUILDER-side
@@ -117,6 +122,8 @@ private sealed class CardModel
public EntityId LeadId;
/// Generation or draw of a completed building; null on unit and site cards.
public string BuildingPowerText;
+ /// The field card's reserve line ("6.420 / 9.000 AE"); null on every entity card.
+ public string FieldReserveText;
public readonly List Buttons = new List(16);
public string QueueHeader;
public readonly List QueueRows = new List(ProductionSystem.MaxQueueEntries);
@@ -134,6 +141,7 @@ public void Clear()
Title = string.Empty;
LeadId = EntityId.Invalid;
BuildingPowerText = null;
+ FieldReserveText = null;
Buttons.Clear();
QueueHeader = null;
QueueRows.Clear();
@@ -149,6 +157,8 @@ public void Clear()
[SerializeField] private RtsDeviceInput _input;
[Tooltip("Build bar; the card docks directly above it.")]
[SerializeField] private BuildMenuHud _buildMenu;
+ [Tooltip("Match bootstrap; the canonical map layout carries each field's initial reserve (21.2, #86).")]
+ [SerializeField] private MatchBootstrap _bootstrap;
[Header("Presentation")]
[Tooltip("Whole panel is scaled by this factor, matching the BuildMenuHud/DebugHud convention for Retina displays.")]
@@ -176,6 +186,7 @@ private void Awake()
if (_runner == null) _runner = FindAnyObjectByType();
if (_input == null) _input = FindAnyObjectByType();
if (_buildMenu == null) _buildMenu = FindAnyObjectByType();
+ if (_bootstrap == null) _bootstrap = FindAnyObjectByType();
}
///
@@ -216,6 +227,16 @@ private void BuildModel(CardModel model)
EntityManager entities = _runner.Entities;
if (entities == null || _input == null) return;
+ // A selected Aetherium field (21.2, #86) owns the card BEFORE any
+ // entity readout: fields are not entities, so the whole entity
+ // path below does not apply to them.
+ ushort selectedFieldId = _input.Selection.SelectedFieldId;
+ if (selectedFieldId != 0)
+ {
+ BuildFieldModel(model, selectedFieldId);
+ return;
+ }
+
ReadOnlySpan selected = _input.Selection.SelectedEntities;
if (selected.Length == 0) return;
if (!entities.TryGetUnit(selected[0], out UnitState lead)) return; // stale handle
@@ -251,6 +272,30 @@ private void BuildModel(CardModel model)
}
}
+ ///
+ /// The field card (21.2, #86): the remaining reserve against the
+ /// canonical initial reserve from the map layout. No buttons — a
+ /// field takes no orders; harvesting stays the Harvester's H
+ /// gesture. An unknown field id or an unwired bootstrap leaves the
+ /// model empty instead of crashing the card on a stale selection.
+ ///
+ private void BuildFieldModel(CardModel model, ushort fieldId)
+ {
+ if (_runner.Economy == null
+ || !_runner.Economy.TryGetField(fieldId, out AetheriumField field)
+ || _bootstrap == null
+ || !_bootstrap.TryGetFieldInitialReserve(fieldId, out long initialReserveAE))
+ {
+ return;
+ }
+
+ model.Title = field.IsExhausted
+ ? "Aetherium-Vorkommen — erschöpft"
+ : "Aetherium-Vorkommen";
+ model.FieldReserveText = CommandCardPresenter.FormatFieldReserveAE(field.RemainingAE, initialReserveAE);
+ 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)
{
@@ -493,6 +538,10 @@ private void OnGUI()
{
GUILayout.Label(model.BuildingPowerText, _rowStyle, GUILayout.Height(RowHeight));
}
+ if (model.FieldReserveText != null)
+ {
+ GUILayout.Label(model.FieldReserveText, _rowStyle, GUILayout.Height(RowHeight));
+ }
if (model.ProgressBar01 >= 0f) DrawProgressBar(model.ProgressBar01);
if (model.SiteStatusText != null)
{
@@ -594,6 +643,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.FieldReserveText != 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/Presentation/UI/RtsDeviceInput.cs b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs
index 7366252..5287888 100644
--- a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs
+++ b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs
@@ -98,6 +98,8 @@ public sealed class RtsDeviceInput : MonoBehaviour
[SerializeField] private float _dragThresholdPixels = 8f;
[Tooltip("Click-select radius in world units (= cells).")]
[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;
[Header("Canonical Alliance definition ids (resolved to the local slot faction at runtime)")]
[Tooltip("B: Power — Alliance defId 5, 450 AE, prerequisite-free.")]
@@ -412,7 +414,8 @@ private void Awake()
$"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" +
- "Camera: arrow keys / screen edge pan | wheel zoom | Z,X rotate | MMB drag rotate | Space reset rotation";
+ "Camera: arrow keys / screen edge pan | wheel zoom | Z,X rotate | MMB drag rotate | Space reset rotation\n" +
+ "Linksklick auf ein Vorkommen: Restbestand anzeigen";
}
private void Update()
@@ -460,6 +463,8 @@ private bool EnsureDispatcher()
_runner.Construction, _runner.Production);
_dispatcher = new RtsIntentDispatcher(ingress, stateView);
_boundIngress = ingress;
+ // ClearSelection also drops the selected field (21.2): a rebinding
+ // means a fresh match, and its ids belong to the old one.
_selection.ClearSelection();
return true;
}
@@ -1298,6 +1303,14 @@ private bool TryPickBuilding(Vector3 world, out EntityId picked, out UnitState b
/// Box select over the ground-projected AABB of all four drag corners.
/// Four, not two: under a tilted camera the screen rectangle projects
/// to a trapezoid, and two corners would clip the selection.
+ ///
+ /// A non-additive box that catches NO units falls through to the field
+ /// pick at the box centre (21.2, #86): on a trackpad a plain "click" is
+ /// a micro-drag past the threshold, and without this fallback that
+ /// gesture over a field read as "clear selection" — the field readout
+ /// was unreachable in the T-02 play observation, while the same gesture
+ /// over a unit still selected it.
+ ///
///
private void SelectBox(Vector2 a, Vector2 b, bool additive)
{
@@ -1315,30 +1328,61 @@ private void SelectBox(Vector2 a, Vector2 b, bool additive)
int count = additive
? _selection.SelectBoxAdditive(_runner.Entities, _dispatcher.LocalSlot, minX, minY, maxX, maxY)
: _selection.SelectBox(_runner.Entities, _dispatcher.LocalSlot, minX, minY, maxX, maxY);
- _lastCommandStatus = additive ? $"Box select (added): {count} unit(s) selected" : $"Box select: {count} unit(s)";
- if (count > 0) AudioServiceLocator.Play2D(SoundEventId.UI_Select);
+ if (count > 0)
+ {
+ _lastCommandStatus = additive ? $"Box select (added): {count} unit(s) selected" : $"Box select: {count} unit(s)";
+ AudioServiceLocator.Play2D(SoundEventId.UI_Select);
+ return;
+ }
+
+ if (!additive)
+ {
+ var centre = new Vector3((minX + maxX) * 0.5f, 0f, (minY + maxY) * 0.5f);
+ if (TryPickField(centre, out ushort fieldId))
+ {
+ _selection.SelectField(fieldId);
+ _lastCommandStatus = $"Selected Aetherium field #{fieldId}";
+ AudioServiceLocator.Play2D(SoundEventId.UI_Select);
+ return;
+ }
+ }
+
+ _lastCommandStatus = additive ? "Box select (added): 0 unit(s) selected" : "Box select: 0 unit(s)";
}
- /// Click select: nearest own active unit within ; 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).
private void SelectSingle(Vector2 screenPoint, bool additive)
{
if (_runner.Entities == null) return;
- if (TryScreenPointToGround(screenPoint, out Vector3 world)
- && TryPickUnit(world, ownedByLocalSlot: true, out EntityId picked))
+ if (TryScreenPointToGround(screenPoint, out Vector3 world))
{
- if (additive)
+ if (TryPickUnit(world, ownedByLocalSlot: true, out EntityId picked))
{
- bool added = _selection.AddSingle(picked);
- _lastCommandStatus = $"Added entity {picked.Index} ({_selection.SelectedCount} selected)";
- if (added) AudioServiceLocator.Play2D(SoundEventId.UI_Select);
+ if (additive)
+ {
+ bool added = _selection.AddSingle(picked);
+ _lastCommandStatus = $"Added entity {picked.Index} ({_selection.SelectedCount} selected)";
+ if (added) AudioServiceLocator.Play2D(SoundEventId.UI_Select);
+ }
+ else
+ {
+ _selection.SelectSingle(picked);
+ _lastCommandStatus = $"Selected entity {picked.Index}";
+ AudioServiceLocator.Play2D(SoundEventId.UI_Select);
+ }
+ return;
}
- else
+
+ // 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.
+ if (!additive && TryPickField(world, out ushort fieldId))
{
- _selection.SelectSingle(picked);
- _lastCommandStatus = $"Selected entity {picked.Index}";
+ _selection.SelectField(fieldId);
+ _lastCommandStatus = $"Selected Aetherium field #{fieldId}";
AudioServiceLocator.Play2D(SoundEventId.UI_Select);
+ return;
}
- return;
}
if (!additive)
@@ -1413,6 +1457,39 @@ private bool TryResolveNearestField(Vector3 world, out ushort fieldId)
return fieldId != 0;
}
+ ///
+ /// The field under a click (21.2, #86): nearest registered field
+ /// whose centre lies within of
+ /// the ground point. Same id-probe pattern as
+ /// , but bounded by the pick
+ /// radius and WITHOUT the exhausted filter — a depleted field must
+ /// stay clickable so its readout (0 AE, erschöpft) remains
+ /// reachable.
+ ///
+ private bool TryPickField(Vector3 world, out ushort fieldId)
+ {
+ fieldId = 0;
+ EconomySystem economy = _runner.Economy;
+ if (economy == null) return false;
+
+ float best = _fieldPickRadiusWorld * _fieldPickRadiusWorld;
+ int found = 0;
+ for (ushort id = 1; id <= EconomySystem.MaxFields && found < economy.FieldCount; id++)
+ {
+ if (!economy.TryGetField(id, out AetheriumField field)) continue;
+ found++;
+
+ float dx = field.GridPos.X + 0.5f - world.x;
+ float dy = field.GridPos.Y + 0.5f - world.z;
+ float distanceSq = dx * dx + dy * dy;
+ if (distanceSq >= best) continue;
+
+ best = distanceSq;
+ fieldId = field.FieldId;
+ }
+ return fieldId != 0;
+ }
+
///
/// Producer for a unit definition: a selected own building of the
/// definition's producer role wins, otherwise the first own building of
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 347ead8..7c5f02c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -73,6 +73,17 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
spielerisch abgenommen und kein Meilenstein-Nachweis
### Hinzugefügt
+- **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 /
+ 9.000 AE", erschöpfte Felder ausdrücklich im Titel markiert), und der
+ Kristallstand jedes Feldes folgt dem Bestand stufenweise bis zum abgedunkelten
+ Stumpf. Reine Präsentation: die Anfangsreserve wird aus der kanonischen
+ Kartenlage (`MatchBootstrap.FieldLayouts`) gelesen, der Simulationszustand
+ bleibt unverändert. Nachklang aus der ersten Spielabnahme: eine leere,
+ nicht-additive Ziehbox fällt jetzt auf den Feld-Pick am Boxzentrum zurück —
+ auf Trackpads ist ein „Klick" ein Mikro-Drag über die 8-px-Schwelle, und ohne
+ den Fallback las die Geste über einem Vorkommen nur „Auswahl leeren"
- **Die Startzone ist gemessen statt geschätzt (Paket 21.1).**
`tools/Nova.SimRunner.Tests/BuildZoneCapacityTests.cs` beziffert in zwei Spuren,
wie viele Gebäude in die Bauzone des kanonischen HQ-Ankers passen: eine echte