From 976dd357ca063520ea5f82bf43c18893f912ec48 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Tue, 18 Aug 2026 13:45:49 +0200 Subject: [PATCH 1/2] feat(presentation): Restbestand der Vorkommen sichtbar machen (21.2, #86) Ein Linksklick auf ein Aetherium-Vorkommen zeigt in der Befehlskarte Restbestand und Anfangsreserve, und der Kristallstand jedes Feldes folgt dem Bestand stufenweise bis zum abgedunkelten Stumpf. Erschoepfte Felder bleiben anklickbar und sind im Titel als solche markiert. Reine Praesentation: die Anfangsreserve wird aus der kanonischen Kartenlage (MatchBootstrap.FieldLayouts) gelesen, der Simulationszustand bleibt unveraendert. Die Stufenregel liegt als reine Funktion ohne UnityEngine-Abhaengigkeit in FieldCrystalStages und ist dadurch vollstaendig per EditMode-Test abgedeckt. Aus der Spielabnahme T-02 mitgenommen: auf dem Trackpad ist ein Klick ein Mikro-Drag ueber die Zieh-Schwelle, wodurch die Geste ueber einem Feld als "Auswahl leeren" gelesen wurde und das Restbestands-Fenster unerreichbar war. Eine Box-Auswahl ohne Treffer greift jetzt auf die Feldauswahl in der Boxmitte durch. Umsetzung durch den Kimi-K3-Worker, Nachweis 723/723 headless. Co-Authored-By: Claude Opus 5 --- .../Gameplay/CommandCardPresenterTests.cs | 29 ++++ .../Gameplay/FieldCrystalStagesTests.cs | 71 ++++++++++ .../Gameplay/FieldCrystalStagesTests.cs.meta | 2 + .../Gameplay/SelectionManagerTests.cs | 61 +++++++++ .../Tests/PlayMode/FieldReservePickTests.cs | 124 ++++++++++++++++++ .../PlayMode/FieldReservePickTests.cs.meta | 2 + .../Editor/BootstrapSceneGenerator.cs | 1 + .../Scripts/Gameplay/Match/MatchBootstrap.cs | 21 +++ .../Gameplay/UI/CommandCardPresenter.cs | 37 ++++++ .../Scripts/Gameplay/UI/FieldCrystalStages.cs | 42 ++++++ .../Gameplay/UI/FieldCrystalStages.cs.meta | 2 + .../Scripts/Gameplay/UI/SelectionManager.cs | 17 +++ .../Maps/GlutrinneBlockoutView.cs | 123 ++++++++++++++++- .../Scripts/Presentation/UI/CommandCardHud.cs | 50 +++++++ .../Scripts/Presentation/UI/RtsDeviceInput.cs | 105 +++++++++++++-- CHANGELOG.md | 11 ++ .../sprint-21/01-kimi-hud-state-audit.md | 0 17 files changed, 682 insertions(+), 16 deletions(-) create mode 100644 Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs create mode 100644 Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs.meta create mode 100644 Assets/Tests/PlayMode/FieldReservePickTests.cs create mode 100644 Assets/Tests/PlayMode/FieldReservePickTests.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs create mode 100644 Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs.meta create mode 100644 reports/v8.6.0/sprint-21/01-kimi-hud-state-audit.md 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 diff --git a/reports/v8.6.0/sprint-21/01-kimi-hud-state-audit.md b/reports/v8.6.0/sprint-21/01-kimi-hud-state-audit.md new file mode 100644 index 0000000..e69de29 From 8ea36348954863677480eec92fd0557b822164f2 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Tue, 18 Aug 2026 13:46:18 +0200 Subject: [PATCH 2/2] chore: leere Report-Platzhalterdatei aus dem Feature-PR nehmen Der Kimi-Audit-Bericht laeuft noch und gehoert nicht in den 21.2-PR; er wird separat abgelegt, sobald er fertig ist. Co-Authored-By: Claude Opus 5 --- reports/v8.6.0/sprint-21/01-kimi-hud-state-audit.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 reports/v8.6.0/sprint-21/01-kimi-hud-state-audit.md diff --git a/reports/v8.6.0/sprint-21/01-kimi-hud-state-audit.md b/reports/v8.6.0/sprint-21/01-kimi-hud-state-audit.md deleted file mode 100644 index e69de29..0000000