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 448180a..8b3612b 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:
@@ -487,6 +491,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
@@ -495,12 +560,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))
{
@@ -508,33 +613,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 e1d0a28..1433b13 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -403,6 +403,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
- **Sammler ernten nicht mehr weiter, wenn das Lager voll ist (#136).** Aus der
Proberunde vom 31.08.2026: „Die ernten ab, bringen das zur Raffinerie, aber
das erhöht den Kontostand nicht. Das vernichtet Material." Genau so war es —
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");
+ }
+ }
+ }
+}