From 7bc3d4c4b7fa7f0d57ef37e1872ffccc7d5670e7 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Mon, 31 Aug 2026 10:47:06 +0200 Subject: [PATCH] fix(construction): die Bauverweigerung nennt den Grund statt nur das Ergebnis (#135) --- .../Scripts/Presentation/UI/BuildMenuHud.cs | 104 ++++- .../Construction/ConstructionSystem.cs | 146 +++++- CHANGELOG.md | 17 + reports/v8.6.0/sprint-23/11-kimi-bauen.md | 158 +++++++ .../ConstructionPlacementDenialTests.cs | 422 ++++++++++++++++++ 5 files changed, 826 insertions(+), 21 deletions(-) create mode 100644 reports/v8.6.0/sprint-23/11-kimi-bauen.md create mode 100644 tools/Nova.SimRunner.Tests/ConstructionPlacementDenialTests.cs diff --git a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs index ca73fb4..edd58eb 100644 --- a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs @@ -27,11 +27,14 @@ namespace Nova.Presentation.UI /// /// /// THE STATUS LINE above the bar always carries the live power balance on - /// the left. Its right side serves three masters in priority order: the - /// hovered entry's blocker or power value, then the D-085 builder warning - /// ("Kein Builder — Bau pausiert…") shown as long as any own construction - /// site has no living Builder — the visible warning instead of the silent - /// dead end —, then the onboarding hint below. + /// the left. Its right side serves four masters in priority order: the + /// hovered entry's blocker or power value, then the live placement + /// denial while the ghost is armed (#135 — the red ghost names its rule + /// here, so "geht nicht" no longer reads as "the unit is broken"), then + /// the D-085 builder warning ("Kein Builder — Bau pausiert…") shown as + /// long as any own construction site has no living Builder — the visible + /// warning instead of the silent dead end —, then the onboarding hint + /// below. /// /// /// DATA SOURCE: — the authoritative static @@ -123,6 +126,7 @@ public sealed class BuildMenuHud : MonoBehaviour private bool _siteLacksBuilder; private int _siteLacksBuilderFrame = -1; private string _hoveredStatusText; + private string _placementStatusText; private string _transientNotice; private float _transientNoticeUntil; @@ -329,6 +333,12 @@ private void DrawBar() } } + // While the placement ghost is armed, the status line's second + // master is the live verdict of the cell under the cursor (#135). + _placementStatusText = ComputePlacementStatusText( + slot, construction, credits, + playerEconomy.PowerProvided, playerEconomy.PowerRequired, activeSiteCount); + // Chrome and status line paint BEFORE the buttons — IMGUI paints // in call order and later calls sit on top. DrawChromeAndStatusLine( @@ -406,14 +416,18 @@ private void DrawChromeAndStatusLine(Rect zone, Rect buttonsRect, string powerBa /// /// What the status line says, in priority order: the hovered entry's /// blocker reason (the player is interrogating that button right - /// now), then the D-085 builder warning while any own site lacks a - /// Builder, then the onboarding hint until it dismisses itself. + /// now), then the live placement denial while the ghost is armed + /// (#135 — the cell under the cursor is what the player is + /// interrogating then), then a transient command notice, then the + /// D-085 builder warning while any own site lacks a Builder, then + /// the onboarding hint until it dismisses itself. /// Null leaves the contextual right side empty; the power balance on /// the left remains visible. /// private string ResolveStatusLineText() { if (_hoveredStatusText != null) return _hoveredStatusText; + if (_placementStatusText != null) return _placementStatusText; if (_transientNotice != null && Time.unscaledTime < _transientNoticeUntil) return _transientNotice; if (_siteLacksBuilder) return ConstructionSiteStatus.NoBuilderWarning; if (!_hintDismissed && _runner.IsRunning) return HintText; @@ -507,6 +521,82 @@ private string BlockerReason( } } + /// + /// The live placement verdict while the ghost is armed: WHY the + /// hovered cell denies, worded as the rule plus the counter-play + /// (issue #135 — a red ghost without a reason read as "the unit is + /// broken": the player tried to place an HQ at the centre fields 56 + /// cells from his base and concluded his second Builder was + /// defective). The credits gate mirrors the executor's order (it + /// charges before validating), then the sim's own fine-grained + /// denial () is + /// asked per frame — the same reads the executor runs, never + /// re-derived, so the text cannot drift from the rule. A legal cell + /// returns null: under a green ghost the line keeps its normal + /// priorities (the onboarding hint must survive the D-077 opening's + /// first placement). + /// + private string ComputePlacementStatusText( + byte slot, ConstructionSystem construction, long credits, + int powerProvided, int powerRequired, int activeSiteCount) + { + if (_input == null || !_input.PlacementModeActive) return null; + if (!_input.TryGetPlacementCell(out int originX, out int originY)) return null; + if (!SimDefinitions.TryGetBuilding(_input.PlacementDefId, out SimBuildingDefinition def)) return null; + + // The ghost verdict combines affordability with the sim walk — + // credits first, exactly like the executor. + if (credits < def.CostAE) + { + return BlockerReason( + def.Role, in def, BuildingPlacementBlocker.InsufficientCredits, + UnitRoleMask.None, credits, powerProvided, powerRequired, activeSiteCount); + } + + switch (construction.GetPlacementDenial(slot, _input.PlacementDefId, originX, originY)) + { + case ConstructionSystem.PlacementDenial.None: + return null; + case ConstructionSystem.PlacementDenial.OutsideBuildInfluence: + // The #135 case: the zone is anchored to finished + // buildings, never to the Builder — the sentence says + // both the rule and the counter-play (chain buildings + // toward the target). + return $"Außerhalb der Bauzone — sie reicht {ConstructionSystem.BuildInfluenceRadiusCells} Felder " + + "um jedes fertige Gebäude. Bau dich mit Gebäuden Richtung Ziel vor."; + case ConstructionSystem.PlacementDenial.TooCloseToBuilding: + return "Zu dicht an einem Gebäude oder einer Baustelle — mindestens ein freies Feld dazwischen."; + case ConstructionSystem.PlacementDenial.FieldSpacingViolated: + return def.Role == UnitRole.Refinery + ? $"Raffinerie braucht ein Vorkommen in {ConstructionSystem.RefineryMinimumFieldDistanceCells} bis {ConstructionSystem.RefineryMaximumFieldDistanceCells} Feldern Entfernung — näher am Aetherium platzieren." + : $"Zu dicht an einem Aetherium-Vorkommen — {ConstructionSystem.MinimumNonRefineryFieldDistanceCells} Felder Abstand nötig (nur die Raffinerie darf näher)."; + case ConstructionSystem.PlacementDenial.FootprintOccupied: + return "Hier steht schon ein Gebäude oder eine Baustelle — freie Zelle wählen."; + case ConstructionSystem.PlacementDenial.FootprintOutsideMap: + return "Das Gebäude würde über den Kartenrand ragen — weiter innen platzieren."; + case ConstructionSystem.PlacementDenial.FootprintOnImpassableTerrain: + return "Unwegsames Gelände — Gebäude brauchen freien Boden."; + case ConstructionSystem.PlacementDenial.MissingPrerequisite: + return BlockerReason( + def.Role, in def, BuildingPlacementBlocker.MissingPrerequisite, + construction.GetMissingPrerequisiteRoles(slot, def.PrerequisiteRoles), + credits, powerProvided, powerRequired, activeSiteCount); + case ConstructionSystem.PlacementDenial.InsufficientPower: + return BlockerReason( + def.Role, in def, BuildingPlacementBlocker.InsufficientPower, + UnitRoleMask.None, credits, powerProvided, powerRequired, activeSiteCount); + case ConstructionSystem.PlacementDenial.SiteCapacityReached: + return BlockerReason( + def.Role, in def, BuildingPlacementBlocker.SiteCapacityReached, + UnitRoleMask.None, credits, powerProvided, powerRequired, activeSiteCount); + default: + // UnknownDefinition / ForeignDefinition: the bar and the + // hotkeys only offer the local faction's own rows, so + // reaching this is defensive only. + return "Dieses Gebäude kannst du nicht bauen."; + } + } + /// /// Hard truncation to a pixel width against the real button style: /// drops characters until the text plus the ellipsis fits. IMGUI does diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index 5c976ab..fa92811 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -38,7 +38,11 @@ namespace Nova.Simulation.Construction /// 128x128 grid or occupied cells; then D-104 terrain, influence, /// building-clearance and Aetherium-field geometry (all /// RejectedInvalidTarget); then missing prerequisite roles, the power rule - /// and site capacity (RejectedPrerequisitesNotMet). + /// and site capacity (RejectedPrerequisitesNotMet). The fine-grained + /// FIRST failure of the same walk is readable through + /// (issue #135): the frozen schema-v1 + /// result codes stay shared between several causes, so the reason the + /// HUD words travels outside the command stream. /// The power rule: a building with PowerRequired > 0 may only be /// placed while the owner's last committed balance /// (previous tick's phase-2 recompute) covers the additional draw: @@ -395,6 +399,67 @@ private static UnitRoleMask RoleMask(UnitRole role) // these at the target tick in the documented fixed order) // ------------------------------------------------------------------ + /// + /// The specific state-dependent reason a placement is denied, in the + /// fixed validation order of (first + /// failure wins, exactly like the validator's short-circuit). Schema + /// v1 deliberately keeps ONE for + /// several of these cases — the code is part of the replay and + /// frozen — so the fine-grained reason travels OUTSIDE the command + /// stream through this read-only enum (issue #135: "geht nicht" was + /// indistinguishable from "the unit is broken"). The presentation + /// layer asks and words the reason; + /// nothing here mutates state or the replay. + /// + public enum PlacementDenial + { + /// Every check passed; returns Applied. + None = 0, + + /// The definition id names no building row (RejectedInvalidTarget). + UnknownDefinition = 1, + + /// The definition id names another faction's row (RejectedInvalidTarget). + ForeignDefinition = 2, + + /// The 3x3 footprint would leave the 128x128 grid (RejectedInvalidTarget). + FootprintOutsideMap = 3, + + /// A footprint cell is already occupied by a placement or site (RejectedInvalidTarget). + FootprintOccupied = 4, + + /// A footprint cell sits on impassable terrain (D-104; RejectedInvalidTarget). + FootprintOnImpassableTerrain = 5, + + /// + /// No own, living, COMPLETED building within + /// (D-108; RejectedInvalidTarget). + /// The build zone is anchored to finished buildings only — never + /// to the Builder unit and never to a construction site. + /// + OutsideBuildInfluence = 6, + + /// Less than to another footprint or site, any owner (RejectedInvalidTarget). + TooCloseToBuilding = 7, + + /// + /// The Aetherium-field geometry fails (D-104; RejectedInvalidTarget): + /// a Refinery needs a field at distance + /// .., + /// every other role must keep . + /// + FieldSpacingViolated = 8, + + /// A completed own building role of the all-of prerequisite mask is missing (D-103; RejectedPrerequisitesNotMet). + MissingPrerequisite = 9, + + /// The last committed power balance does not cover the new draw (RejectedPrerequisitesNotMet). + InsufficientPower = 10, + + /// The site register is full (; RejectedPrerequisitesNotMet). + SiteCapacityReached = 11, + } + /// /// Full state-dependent placement validation in fixed order: unknown /// definition, foreign-faction definition, out-of-map footprint and @@ -403,12 +468,52 @@ private static UnitRoleMask RoleMask(UnitRole role) /// roles, power rule and site capacity (RejectedPrerequisitesNotMet). /// Cost is the executor's separate check /// (RejectedInsufficientResources) and runs BEFORE this. + /// + /// The result is the coarse schema-v1 mapping of + /// — this method IS that walk mapped + /// onto , so the coarse code and the + /// fine-grained reason can never drift apart. Behavior is unchanged: + /// the same first failure short-circuits, in the same order. + /// /// public CommandResultCode ValidatePlacement(byte playerSlot, ushort buildingDefId, int originX, int originY) + { + switch (GetPlacementDenial(playerSlot, buildingDefId, originX, originY)) + { + case PlacementDenial.None: + return CommandResultCode.Applied; + case PlacementDenial.MissingPrerequisite: + case PlacementDenial.InsufficientPower: + case PlacementDenial.SiteCapacityReached: + return CommandResultCode.RejectedPrerequisitesNotMet; + default: + return CommandResultCode.RejectedInvalidTarget; + } + } + + /// + /// The fine-grained read of : the + /// FIRST state-dependent denial in the validator's own fixed order, + /// or when the placement is legal. + /// Pure read, no mutation, no cost check (credits are the executor's + /// separate gate, evaluated before this walk). + /// + /// THIS METHOD IS THE REASON SURFACE, public so the presentation + /// layer can ASK why a cell is denied instead of re-deriving the + /// checks — the same contract that made + /// and + /// public for the build-zone + /// overlay. It answers for a single footprint origin, so it stays + /// cheap enough to ask per frame while the placement ghost is armed + /// (issue #135: the HUD names the rule, the sim stays the only + /// owner of the rule). + /// + /// + public PlacementDenial GetPlacementDenial(byte playerSlot, ushort buildingDefId, int originX, int originY) { if (!SimDefinitions.TryGetBuilding(buildingDefId, out SimBuildingDefinition def)) { - return CommandResultCode.RejectedInvalidTarget; + return PlacementDenial.UnknownDefinition; } if (def.Faction != _economy.GetSlotFaction(playerSlot)) { @@ -416,33 +521,46 @@ public CommandResultCode ValidatePlacement(byte playerSlot, ushort buildingDefId // its own faction's rows. A foreign id is a known id naming // content the slot cannot build — an invalid target, exactly // like an unknown one. - return CommandResultCode.RejectedInvalidTarget; + return PlacementDenial.ForeignDefinition; } - if (!FootprintInsideMap(originX, originY) || !FootprintFree(originX, originY)) + if (!FootprintInsideMap(originX, originY)) { - return CommandResultCode.RejectedInvalidTarget; + return PlacementDenial.FootprintOutsideMap; } - if (!FootprintIsWalkable(originX, originY) - || !IsInsideBuildInfluence(playerSlot, originX, originY) - || !HasMinimumBuildingSpacing(originX, originY) - || !HasValidFieldSpacing(def.Role, originX, originY)) + if (!FootprintFree(originX, originY)) { - return CommandResultCode.RejectedInvalidTarget; + return PlacementDenial.FootprintOccupied; + } + if (!FootprintIsWalkable(originX, originY)) + { + return PlacementDenial.FootprintOnImpassableTerrain; + } + if (!IsInsideBuildInfluence(playerSlot, originX, originY)) + { + return PlacementDenial.OutsideBuildInfluence; + } + if (!HasMinimumBuildingSpacing(originX, originY)) + { + return PlacementDenial.TooCloseToBuilding; + } + if (!HasValidFieldSpacing(def.Role, originX, originY)) + { + return PlacementDenial.FieldSpacingViolated; } if (!HasFinishedBuildings(playerSlot, def.PrerequisiteRoles)) { - return CommandResultCode.RejectedPrerequisitesNotMet; + return PlacementDenial.MissingPrerequisite; } ref readonly PlayerEconomyState eco = ref _economy.GetPlayerEconomy(playerSlot); if (def.PowerRequired > 0 && eco.PowerProvided - eco.PowerRequired < def.PowerRequired) { - return CommandResultCode.RejectedPrerequisitesNotMet; + return PlacementDenial.InsufficientPower; } if (FreeSiteIndex() < 0) { - return CommandResultCode.RejectedPrerequisitesNotMet; + return PlacementDenial.SiteCapacityReached; } - return CommandResultCode.Applied; + return PlacementDenial.None; } /// CancelConstruction legality: the entity must be an active site owned by the slot. diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f2c56..ff7eaa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -371,6 +371,23 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt ### Behoben +- **Die Bauverweigerung nennt jetzt den Grund (#135).** Aus der Proberunde vom + 31.08.2026 kamen zwei Beschwerden, die sich als **dieselbe** erwiesen: „Der + zweite Atlas kann keine Gebäude bauen" und „Ich konnte in der Kartenmitte kein + Hauptquartier bauen, obwohl ich genug Strom und Geld hatte." Die Simulation + hatte in beiden Fällen recht — `IsInsideBuildInfluence` misst die Bauzone von + den eigenen **Gebäuden** aus, nicht vom Pionier, und die Mitte liegt 56 Zellen + vom HQ entfernt bei einem Radius von 8. Der Defekt lag darin, dass + `ValidatePlacement` vier verschiedene Regeln in **einen** Rückgabewert warf: + der Spieler bekam „geht nicht" und musste raten, und bei genug Geld und Strom + ist „die Einheit ist kaputt" der vernünftigste Schluss. Die Gründe sind jetzt + unterscheidbar, und die Meldungen nennen die **Regel** statt des Ergebnisses — + „Außerhalb der Bauzone — sie reicht 8 Felder um jedes fertige Gebäude" statt + „ungültiges Ziel". Die Texte lesen die tatsächlichen Konstanten, können also + nicht veralten. Das eingefrorene `CommandsV1`-Schema bleibt unangetastet: der + feine Grund reist über ein schreibgeschütztes Enum außerhalb des + Befehlsstroms, ermittelt vom **selben** Durchlauf wie die Prüfung, damit + Ergebnis und Begründung nicht auseinanderlaufen können. Verhalten unverändert - **Eine einseitige Änderung der Geländetabelle blieb in der CI unsichtbar.** Die kanonische Tabelle steht notgedrungen zweimal im Repo — `GlutrinneTerrainMap` auf der Unity-Seite und ein handgespiegelter Ausdruck in diff --git a/reports/v8.6.0/sprint-23/11-kimi-bauen.md b/reports/v8.6.0/sprint-23/11-kimi-bauen.md new file mode 100644 index 0000000..38fb4af --- /dev/null +++ b/reports/v8.6.0/sprint-23/11-kimi-bauen.md @@ -0,0 +1,158 @@ +# kimi-agent report + +- when: 2026-08-31T07:50:22Z +- backend: cc +- model: k3[1m] +- mode: rw +- dir: /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/bauen +- run: /Users/denniswestermann/.agent-runs/20260831-095022-81019 + +## Task + +Du arbeitest an "Project Nova" / HashKrieg, einem Unity-RTS mit deterministischer, +ganzzahliger Simulation. Doku und Berichte: Deutsch. Code und Docstrings: +Englisch, wie im Bestand. + +**ARBEITSVERZEICHNIS — der einzige Pfad, unter dem du liest und schreibst:** + + /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/bauen + +Daneben liegt eine Arbeitskopie unter `/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova`. +**Fass die nicht an, weder lesend noch schreibend.** + +## Der Befund — Issue #135, lies ihn zuerst + +Der Inhaber hat am 31.08.2026 gespielt und zwei Dinge gemeldet: + +> „Ich habe einen zweiten Atlas gebaut. Der hat keine Funktion, der kann keine +> Gebäude bauen." +> +> „Ich wollte bei den Vorkommen in der Mitte ein Hauptquartier bauen. Das ging +> nicht, obwohl ich genug Strom hatte und auch genug Geld." + +**Beides ist derselbe Fall, und die Simulation hat recht.** +`ConstructionSystem.IsInsideBuildInfluence` misst die Bauzone von den eigenen +fertigen **Gebäuden** aus, nicht vom Pionier. Der zweite Atlas ist nicht kaputt; +er stand dort, wo niemand bauen darf. Und die Kartenmitte liegt 56 Zellen vom +HQ entfernt, bei `BuildInfluenceRadiusCells = 8`. + +**Das Verhalten bleibt. Es geht ausschließlich darum, dass der Spieler es +erfahren kann.** D-108 hat die Regel bewusst so gesetzt, und die Karte aus +Sprint 21 ist auf sie hin gebaut — wer sie ändert, macht den Sprint kaputt. + +## Der eigentliche Defekt + +`ConstructionSystem.ValidatePlacement` wirft vier Ursachen in **einen** +Rückgabewert (`CommandResultCode.RejectedInvalidTarget`): + +- außerhalb der Karte oder besetzt +- `!IsInsideBuildInfluence` — zu weit von den eigenen Gebäuden +- `!HasMinimumBuildingSpacing` — zu dicht am Nachbarn +- `!HasValidFieldSpacing` — falscher Abstand zum Vorkommen + +Der Spieler bekommt „geht nicht" und muss raten. Bei genug Geld und Strom ist +„die Einheit ist kaputt" der naheliegendste Schluss — und genau den hat er +gezogen. + +## Deine drei Aufgaben + +**1. Die Ursachen trennen.** Vier unterscheidbare Gründe statt eines. Wie du +das schneidest, entscheidest du — es gibt bereits ein +`BuildingPlacementBlocker`-Konzept in `BuildMenuHud`, sieh dir an, ob das der +richtige Träger ist oder ob die Simulation einen eigenen Grund zurückgeben +sollte. + +> **Die eingefrorene Grenze beachten:** `Simulation/CommandsV1/` ist +> D-ID-pflichtig. Wenn ein neuer `CommandResultCode` das Schema berührt, +> **halt an und melde es** — dann ist der richtige Weg, den Grund +> präsentationsseitig zu ermitteln, indem die vorhandenen öffentlichen Prüfungen +> (`IsInsideBuildInfluence`, `HasMinimumBuildingSpacing`, …) einzeln gefragt +> werden. Prüf, welche davon schon öffentlich sind. + +**2. Die Meldung muss die Regel nennen, nicht das Ergebnis.** Nicht „ungültiges +Ziel", sondern der Satz, der den Spieler handeln lässt: *warum* nicht, und *was +stattdessen*. Bei der Bauzone ist die Antwort „deine Bauzone reicht acht Felder +um jedes fertige Gebäude — bau dich in Richtung Mitte vor". Schreib die Texte +auf Deutsch, im Ton des übrigen Spiel-UI. + +**3. Prüf, ob das Bauzonen-Overlay in der Kartenmitte überhaupt zeichnet.** +`BuildZoneOverlayView` zeigt sich während des Platzierens (`:107`, an +`PlacementModeActive`). Wenn es dort korrekt „nichts baubar" malt, hat der +Spieler es übersehen und Aufgabe 2 genügt. **Wenn es dort gar nicht zeichnet, +ist „nicht baubar" von „nicht gezeichnet" nicht unterscheidbar — und das wäre +ein eigener Fehler, den du melden musst.** Sieh dir an, ob das Quad die ganze +Karte abdeckt oder nur einen Ausschnitt. + +Das ist die Frage, deren Antwort ich am wenigsten kenne. Beantworte sie +sauber, auch wenn sie „das Overlay ist in Ordnung" lautet. + +## Schreibhoheit — verbindlich + +ERLAUBT: + Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs + nur lesende Flächen / Rückgabegründe + Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs + Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs + Assets/Tests/EditMode/Simulation/ Tests zu den getrennten Gründen + tools/Nova.SimRunner.Tests/ dito + reports/v8.6.0/sprint-23/ nur deine eigenen Dateien + +VERBOTEN: + Assets/_Project/Scripts/Simulation/Economy/ dort arbeitet ein anderer Worker + Assets/_Project/Scripts/Simulation/CommandsV1|Snapshots|Replays|Systems|State/ + Assets/_Project/Scripts/Simulation/Combat|Movement|Factions|Pathfinding/ + Assets/_Project/Scripts/AI/ AI.Data/ + Assets/_Project/Scripts/Presentation/UI/DebugHud.cs + Assets/_Project/Scripts/Presentation/UI/ResourceBarHud.cs (falls vorhanden — anderer Worker) + Assets/_Project/Scripts/Gameplay/Match/ Presentation/Maps/ + CHANGELOG.md VERSION ROADMAP.md README.md plans/** global.json + +**Den CHANGELOG fasst du nicht an.** Vorschlagstext in den Report. + +## Handwerkliches + +Zwei Nachträge pro neuer HUD-Fläche, im Bestand mehrfach schiefgegangen: +`EstimateHeight` bildet die Höhenrechnung von `OnGUI` Zeile für Zeile nach, und +jede neue Trefferfläche gehört in `IsPointerOverHud` — sonst schlagen Klicks +dahinter in die Welt durch. + +Neue `.cs` unter `Assets/` brauchen eine `.meta`-Schwester: +`fileFormatVersion: 2` plus `guid:` mit 32 neuen Hex-Zeichen. + +## Verifikation + + "/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet/dotnet" test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release + +Ausgangsstand **739/739 grün** — er muss grün bleiben. Vorher und nachher +fahren, beides wörtlich in den Report. Unity hast du nicht: die +Präsentationsseite ist nur durch sorgfältiges Lesen abgesichert. Sag im Report, +was unbelegt bleibt. + +## Was du NICHT tust + +- Kein `git commit`, `git push`, `git add`, kein PR, kein `gh`-Aufruf. +- Keine Subagenten. +- **Die Bauzonenregel selbst ändern.** Radius, Mindestabstand und Feldabstände + bleiben, wie sie sind. + +## Report + +Markdown nach `reports/v8.6.0/sprint-23/`. Struktur: + + 1. Wie du die vier Gründe getrennt hast, und ob die Simulation dafür etwas + Neues zurückgibt oder die Präsentation einzeln fragt + 2. Die Meldungstexte, wörtlich + 3. **Die Antwort auf Frage 3: zeichnet das Overlay in der Mitte?** Mit Beleg + 4. Testlauf vorher / nachher + 5. Was unbelegt bleibt und wie der Inhaber es nachprüft + 6. CHANGELOG-Vorschlagstext + +Schließe mit: + + STATUS: DONE | BLOCKED + - Befund 1 + - Befund 2 + - Befund 3 + +## Output + diff --git a/tools/Nova.SimRunner.Tests/ConstructionPlacementDenialTests.cs b/tools/Nova.SimRunner.Tests/ConstructionPlacementDenialTests.cs new file mode 100644 index 0000000..c81f628 --- /dev/null +++ b/tools/Nova.SimRunner.Tests/ConstructionPlacementDenialTests.cs @@ -0,0 +1,422 @@ +using NUnit.Framework; +using Nova.Core; +using Nova.Simulation; +using Nova.Simulation.CommandsV1; +using Nova.Simulation.Construction; +using Nova.Simulation.Definitions; +using Nova.Simulation.Economy; +using Nova.Simulation.Pathfinding; +using Nova.Simulation.State; + +namespace Nova.SimRunner.Tests +{ + /// + /// Issue #135: the fine-grained placement denial. Schema v1 deliberately + /// keeps ONE CommandResultCode for every geometry cause + /// (RejectedInvalidTarget) — the code is frozen replay content — so the + /// distinguishable reason travels outside the command stream through the + /// read-only . This + /// suite pins every denial value in its own scenario, the first-failure + /// order where causes overlap, and — over the full 128x128 grid — the + /// exact agreement between the new reason surface and the unchanged + /// mapping, including + /// the owner's own case (HQ at the start, money and power fine, the + /// contested centre field 50+ cells away: denied, and the reason is the + /// build zone, never the Builder). + /// + [TestFixture] + public sealed class ConstructionPlacementDenialTests + { + // Alliance definition ids (SimDefinitions id rule: the Alliance id IS + // the role wire value). + private const ushort Hq = 3; + private const ushort Refinery = 4; + private const ushort Power = 5; + private const ushort Barracks = 7; + + private sealed class Fixture + { + public EntityManager Entities { get; } + public EconomySystem Economy { get; } + public CostField CostField { get; } + public ConstructionSystem Construction { get; } + public SimulationKernel Kernel { get; } + + public Fixture( + long startingCredits = 1000, + System.Action configure = null, + bool addDefaultField = true, + int entityCapacity = 64) + { + Entities = new EntityManager(entityCapacity); + Economy = new EconomySystem(Entities, startingCredits); + 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); + // Pre-start configuration hook (e.g. slot factions, fields): + // the SetSlotFaction guard locks the assignment at Start(). + configure?.Invoke(Economy); + if (addDefaultField && Economy.FieldCount == 0) + { + Economy.TryAddField(63, new GridPos2D(20, 24), 9000); + } + Kernel.Start(); + } + + public void Step(int ticks) + { + for (int i = 0; i < ticks; i++) Kernel.StepTick(); + } + } + + [Test] + public void GetPlacementDenial_UnknownDefinition_IsUnknownDefinition() + { + var f = new Fixture(); + Assert.That(f.Construction.GetPlacementDenial(0, 99, 30, 30), + Is.EqualTo(ConstructionSystem.PlacementDenial.UnknownDefinition)); + Assert.That(f.Construction.ValidatePlacement(0, 99, 30, 30), + Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "the frozen schema-v1 bucket for the same cause"); + } + + [Test] + public void GetPlacementDenial_ForeignFactionDefinition_IsForeignDefinition() + { + var f = new Fixture(configure: e => e.SetSlotFaction(1, FactionId.Legion)); + Assert.That(f.Construction.GetPlacementDenial(1, Barracks, 30, 30), + Is.EqualTo(ConstructionSystem.PlacementDenial.ForeignDefinition), + "a Legion slot naming the Alliance Barracks row"); + Assert.That(f.Construction.ValidatePlacement(1, Barracks, 30, 30), + Is.EqualTo(CommandResultCode.RejectedInvalidTarget)); + } + + [Test] + public void GetPlacementDenial_OutOfMapFootprint_IsFootprintOutsideMap() + { + var f = new Fixture(); + Assert.That(f.Construction.GetPlacementDenial(0, Power, 126, 126), + Is.EqualTo(ConstructionSystem.PlacementDenial.FootprintOutsideMap), + "the 3x3 footprint must fit the 128x128 grid"); + Assert.That(f.Construction.GetPlacementDenial(0, Power, -2, 30), + Is.EqualTo(ConstructionSystem.PlacementDenial.FootprintOutsideMap), + "a negative origin leaves the grid on the other side"); + Assert.That(f.Construction.ValidatePlacement(0, Power, 126, 126), + Is.EqualTo(CommandResultCode.RejectedInvalidTarget)); + } + + [Test] + public void GetPlacementDenial_OccupiedFootprint_IsFootprintOccupied() + { + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Power, 20, 20).IsValid, Is.True); + Assert.That(f.Construction.GetPlacementDenial(0, Power, 21, 21), + Is.EqualTo(ConstructionSystem.PlacementDenial.FootprintOccupied), + "the footprints overlap — distinguishable from a mere spacing violation"); + Assert.That(f.Construction.ValidatePlacement(0, Power, 21, 21), + Is.EqualTo(CommandResultCode.RejectedInvalidTarget)); + } + + [Test] + public void GetPlacementDenial_ImpassableTerrain_IsFootprintOnImpassableTerrain_AndBeatsInfluence() + { + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Hq, 12, 20).IsValid, Is.True); + + f.CostField.SetCost(22, 22, CostField.ImpassableCost); + Assert.That(f.Construction.GetPlacementDenial(0, Power, 20, 20), + Is.EqualTo(ConstructionSystem.PlacementDenial.FootprintOnImpassableTerrain), + "one impassable cell denies the whole footprint, inside the zone"); + Assert.That(f.Construction.ValidatePlacement(0, Power, 20, 20), + Is.EqualTo(CommandResultCode.RejectedInvalidTarget)); + + f.CostField.SetCost(62, 62, CostField.ImpassableCost); + Assert.That(f.Construction.GetPlacementDenial(0, Power, 61, 61), + Is.EqualTo(ConstructionSystem.PlacementDenial.FootprintOnImpassableTerrain), + "first failure wins: terrain precedes the build influence in the validator's order"); + } + + [Test] + public void GetPlacementDenial_CentreFieldWithMoneyAndPower_IsOutsideBuildInfluence_Issue135() + { + // The owner's exact report: HQ at the start, a second Atlas at the + // contested centre field, enough credits and power — and the + // placement still fails, because the zone is anchored to finished + // BUILDINGS (D-108), never to the Builder unit. + var f = new Fixture( + startingCredits: 6000, + configure: e => e.TryAddField(1, new GridPos2D(62, 62), 15000), + addDefaultField: false); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Hq, 7, 7).IsValid, Is.True, + "the start HQ near the canonical start position"); + f.Step(1); // commit the balance: 30 provided, 0 required — power is fine + + Assert.That(f.Construction.GetPlacementDenial(0, Hq, 62, 62), + Is.EqualTo(ConstructionSystem.PlacementDenial.OutsideBuildInfluence), + "the centre sits ~53 footprint cells from the only anchor; BuildInfluenceRadiusCells is 8"); + Assert.That(f.Construction.ValidatePlacement(0, Hq, 62, 62), + Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "the old opaque answer for the identical cell"); + + f.Entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(62), SimFixed.FromInt(62)), + SimFixed.FromInt(3), + role: UnitRole.Builder); + Assert.That(f.Construction.GetPlacementDenial(0, Hq, 62, 62), + Is.EqualTo(ConstructionSystem.PlacementDenial.OutsideBuildInfluence), + "the second Atlas standing right there carries no zone — he is not broken"); + + // The counter-play the HUD sentence names: chain finished + // buildings toward the target and the SAME kind of cell turns + // legal (one footprint row off the field cell itself, which no + // non-Refinery may ever cover). + Assert.That(f.Construction.PlaceCompletedBuilding(0, Power, 54, 56).IsValid, Is.True, + "a chained anchor reaches the centre (PlaceCompletedBuilding is the setup shortcut)"); + f.Step(1); + Assert.That(f.Construction.GetPlacementDenial(0, Hq, 62, 57), + Is.EqualTo(ConstructionSystem.PlacementDenial.None), + "inside the chained zone, off the field cell, the HQ placement is legal"); + } + + [Test] + public void GetPlacementDenial_AdjacentFootprint_IsTooCloseToBuilding_AfterInfluence() + { + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Hq, 10, 10).IsValid, Is.True); + Assert.That(f.Construction.GetPlacementDenial(0, Power, 13, 10), + Is.EqualTo(ConstructionSystem.PlacementDenial.TooCloseToBuilding), + "edge-adjacent footprints have distance 1 < MinimumBuildingDistanceCells"); + Assert.That(f.Construction.ValidatePlacement(0, Power, 13, 10), + Is.EqualTo(CommandResultCode.RejectedInvalidTarget)); + + // An ENEMY building blocks spacing too — but where the own zone + // does not reach, the influence failure is reported first. + Assert.That(f.Construction.PlaceCompletedBuilding(1, Hq, 40, 40).IsValid, Is.True); + Assert.That(f.Construction.GetPlacementDenial(0, Power, 43, 40), + Is.EqualTo(ConstructionSystem.PlacementDenial.OutsideBuildInfluence), + "first failure wins: outside the own zone masks the enemy-adjacent spacing violation"); + } + + [Test] + public void GetPlacementDenial_FieldGeometry_IsFieldSpacingViolated_PerRole() + { + var f = new Fixture(); // default field at (20,24) + Assert.That(f.Construction.PlaceCompletedBuilding(0, Hq, 12, 18).IsValid, Is.True); + f.Step(1); + + Assert.That(f.Construction.GetPlacementDenial(0, Power, 18, 22), + Is.EqualTo(ConstructionSystem.PlacementDenial.FieldSpacingViolated), + "a non-Refinery footprint covering the field cell itself (distance 0 < 2)"); + Assert.That(f.Construction.GetPlacementDenial(0, Refinery, 18, 14), + Is.EqualTo(ConstructionSystem.PlacementDenial.FieldSpacingViolated), + "a Refinery inside the zone but without a field at distance 1..3 — inverted rule, same denial"); + Assert.That(f.Construction.GetPlacementDenial(0, Refinery, 22, 22), + Is.EqualTo(ConstructionSystem.PlacementDenial.None), + "the Refinery at field distance 2 inside the zone passes every check"); + } + + [Test] + public void GetPlacementDenial_MissingPrerequisite_IsMissingPrerequisite() + { + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Hq, 12, 20).IsValid, Is.True); + f.Step(1); + + // Geometry passes (influence 6, spacing 6, field distance 2) and + // the power rule passes (30 free >= 15): the all-of prerequisite + // HQ + Power is the FIRST failure. + Assert.That(f.Construction.GetPlacementDenial(0, Barracks, 20, 20), + Is.EqualTo(ConstructionSystem.PlacementDenial.MissingPrerequisite)); + Assert.That(f.Construction.ValidatePlacement(0, Barracks, 20, 20), + Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet)); + } + + [Test] + public void GetPlacementDenial_UncoveredPowerDraw_IsInsufficientPower() + { + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Hq, 12, 20).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Refinery, 30, 20).IsValid, Is.True, + "20 power draw (the setup shortcut bypasses field geometry by contract)"); + f.Step(1); // commit: 30 provided, 20 required — 10 free + + Assert.That(f.Construction.GetPlacementDenial(0, Refinery, 22, 22), + Is.EqualTo(ConstructionSystem.PlacementDenial.InsufficientPower), + "the second Refinery draws 20, only 10 are free"); + Assert.That(f.Construction.ValidatePlacement(0, Refinery, 22, 22), + Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet)); + } + + [Test] + public void GetPlacementDenial_FullSiteRegister_IsSiteCapacityReached() + { + var f = new Fixture(startingCredits: 1000000, entityCapacity: 160); + // Four completed anchors, each covering a 4x4 cluster of site + // origins (offsets ±4/±8 — every origin well inside the radius-8 + // zone). The HQ anchor satisfies the Power plant's HQ + // prerequisite; the Power plant draws nothing, so the power rule + // never fires for these sites. + Assert.That(f.Construction.PlaceCompletedBuilding(0, Hq, 20, 20).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Power, 20, 90).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Power, 90, 20).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Power, 90, 90).IsValid, Is.True); + f.Step(1); + + int placed = 0; + foreach (var anchor in new[] { (20, 20), (20, 90), (90, 20), (90, 90) }) + { + foreach (int dx in new[] { -8, -4, 4, 8 }) + { + foreach (int dy in new[] { -8, -4, 4, 8 }) + { + int originX = anchor.Item1 + dx; + int originY = anchor.Item2 + dy; + Assert.That(f.Construction.TryPlaceBuilding(0, Power, originX, originY), Is.True, + $"site {placed + 1} at ({originX},{originY})"); + placed++; + } + } + } + Assert.That(placed, Is.EqualTo(ConstructionSystem.MaxSites)); + Assert.That(f.Construction.SiteCount, Is.EqualTo(ConstructionSystem.MaxSites)); + + // The 65th: free cells, inside the zone, spacing kept, field + // distance kept, prerequisite met, no power draw — only the + // register is full. + Assert.That(f.Construction.GetPlacementDenial(0, Power, 12, 20), + Is.EqualTo(ConstructionSystem.PlacementDenial.SiteCapacityReached)); + Assert.That(f.Construction.ValidatePlacement(0, Power, 12, 20), + Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet)); + } + + [Test] + public void GetPlacementDenial_LegalCell_IsNone_AndValidatePlacementApplies() + { + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Hq, 12, 20).IsValid, Is.True); + f.Step(1); + + Assert.That(f.Construction.GetPlacementDenial(0, Power, 20, 20), + Is.EqualTo(ConstructionSystem.PlacementDenial.None)); + Assert.That(f.Construction.ValidatePlacement(0, Power, 20, 20), + Is.EqualTo(CommandResultCode.Applied)); + Assert.That(f.Construction.SiteCount, Is.EqualTo(0), "both reads are pure — nothing mutates"); + } + + [Test] + public void GetPlacementDenial_TheFourCollapsedCauses_AreNowDistinct() + { + // The #135 defect: these four causes shared ONE result code. + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Hq, 12, 20).IsValid, Is.True); + f.Step(1); + + var occupied = f.Construction.GetPlacementDenial(0, Power, 13, 21); + var influence = f.Construction.GetPlacementDenial(0, Power, 60, 60); + var spacing = f.Construction.GetPlacementDenial(0, Power, 15, 20); + var field = f.Construction.GetPlacementDenial(0, Power, 18, 22); + + Assert.That( + new[] { occupied, influence, spacing, field }, + Is.EquivalentTo(new[] + { + ConstructionSystem.PlacementDenial.FootprintOccupied, + ConstructionSystem.PlacementDenial.OutsideBuildInfluence, + ConstructionSystem.PlacementDenial.TooCloseToBuilding, + ConstructionSystem.PlacementDenial.FieldSpacingViolated, + }), + "four distinguishable reasons where the player used to read one"); + Assert.That(f.Construction.ValidatePlacement(0, Power, 13, 21), + Is.EqualTo(CommandResultCode.RejectedInvalidTarget), "occupied: the frozen code stays the shared bucket"); + Assert.That(f.Construction.ValidatePlacement(0, Power, 60, 60), + Is.EqualTo(CommandResultCode.RejectedInvalidTarget), "influence: the frozen code stays the shared bucket"); + Assert.That(f.Construction.ValidatePlacement(0, Power, 15, 20), + Is.EqualTo(CommandResultCode.RejectedInvalidTarget), "spacing: the frozen code stays the shared bucket"); + Assert.That(f.Construction.ValidatePlacement(0, Power, 18, 22), + Is.EqualTo(CommandResultCode.RejectedInvalidTarget), "field: the frozen code stays the shared bucket"); + } + + [Test] + public void GetPlacementDenial_AgreesWithValidatePlacement_OnEveryCell() + { + // The anti-drift pin: GetPlacementDenial is not a second + // validator — ValidatePlacement maps its result. Over the full + // grid, with own anchors, an enemy building, an impassable patch + // and two fields, the reason and the frozen code must agree + // EVERYWHERE: None <=> Applied, the three register/economy + // denials <=> RejectedPrerequisitesNotMet, everything else <=> + // RejectedInvalidTarget. + var f = new Fixture( + configure: e => + { + e.TryAddField(63, new GridPos2D(20, 24), 9000); + e.TryAddField(2, new GridPos2D(64, 64), 9000); + }); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Hq, 12, 20).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, Power, 60, 60).IsValid, Is.True, + "second anchor near the (64,64) field; its 100 power keep the Refinery affordable on the power rule"); + Assert.That(f.Construction.PlaceCompletedBuilding(1, Hq, 100, 100).IsValid, Is.True); + for (int y = 30; y <= 32; y++) + { + for (int x = 30; x <= 32; x++) + { + f.CostField.SetCost((ushort)x, (ushort)y, CostField.ImpassableCost); + } + } + f.Step(1); + + int size = ConstructionSystem.GridSize; + foreach (ushort defId in new[] { Power, Refinery }) + { + var seen = new bool[12]; + for (int originY = 0; originY < size; originY++) + { + for (int originX = 0; originX < size; originX++) + { + ConstructionSystem.PlacementDenial denial = + f.Construction.GetPlacementDenial(0, defId, originX, originY); + CommandResultCode code = + f.Construction.ValidatePlacement(0, defId, originX, originY); + seen[(int)denial] = true; + + if (denial == ConstructionSystem.PlacementDenial.None) + { + Assert.That(code, Is.EqualTo(CommandResultCode.Applied), + $"def {defId} at ({originX},{originY}): no denial must validate"); + } + else if (denial == ConstructionSystem.PlacementDenial.MissingPrerequisite + || denial == ConstructionSystem.PlacementDenial.InsufficientPower + || denial == ConstructionSystem.PlacementDenial.SiteCapacityReached) + { + Assert.That(code, Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet), + $"def {defId} at ({originX},{originY}): {denial}"); + } + else + { + Assert.That(code, Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + $"def {defId} at ({originX},{originY}): {denial}"); + } + } + } + + Assert.That(seen[(int)ConstructionSystem.PlacementDenial.None], Is.True, + $"def {defId}: some cell on the map is placeable"); + Assert.That(seen[(int)ConstructionSystem.PlacementDenial.FootprintOutsideMap], Is.True, + $"def {defId}: the map edge denies on bounds"); + Assert.That(seen[(int)ConstructionSystem.PlacementDenial.FootprintOccupied], Is.True, + $"def {defId}: an anchor's own cells deny on occupancy"); + Assert.That(seen[(int)ConstructionSystem.PlacementDenial.FootprintOnImpassableTerrain], Is.True, + $"def {defId}: the impassable patch denies on terrain"); + Assert.That(seen[(int)ConstructionSystem.PlacementDenial.OutsideBuildInfluence], Is.True, + $"def {defId}: the far map denies on influence"); + Assert.That(seen[(int)ConstructionSystem.PlacementDenial.TooCloseToBuilding], Is.True, + $"def {defId}: an anchor's ring denies on spacing"); + Assert.That(seen[(int)ConstructionSystem.PlacementDenial.FieldSpacingViolated], Is.True, + $"def {defId}: field geometry denies somewhere on the grid"); + } + } + } +}