diff --git a/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs b/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs
new file mode 100644
index 0000000..1a843ce
--- /dev/null
+++ b/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs
@@ -0,0 +1,108 @@
+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.Simulation.Tests
+{
+ ///
+ /// Sprint 21 package 21.4 (issue #91): pins the contract the build-zone
+ /// overlay relies on. The overlay must never re-derive the zone rule, so
+ /// what is pinned here is the RELATION between the two public reads and
+ /// the validator, never the values behind them — no radius, no anchor
+ /// roles: when D-108 re-opens the anchor list, this suite must keep
+ /// passing untouched, with the picture simply following the simulation.
+ ///
+ /// Two statements: (1) IsInsideBuildInfluence and
+ /// HasMinimumBuildingSpacing are NECESSARY for an accepted placement —
+ /// a cell either query rejects must never validate, or the overlay would
+ /// paint "outside/blocked" on cells a click could still place at.
+ /// (2) The pair distinguishes "outside the zone" from "inside the zone
+ /// but spacing-blocked" — the two states the overlay paints differently,
+ /// and the confusion the test report complained about.
+ ///
+ ///
+ [TestFixture]
+ public class BuildZoneOverlayQueryTests
+ {
+ private const byte Slot = 0;
+ private const ushort DefHQAlliance = 3;
+ private const ushort DefPowerAlliance = 5;
+
+ private sealed class Fixture
+ {
+ public ConstructionSystem Construction { get; }
+
+ public Fixture()
+ {
+ var entities = new EntityManager(64);
+ var economy = new EconomySystem(entities, 1000);
+ var costField = new CostField(ConstructionSystem.GridSize, ConstructionSystem.GridSize);
+ Construction = new ConstructionSystem(entities, economy, costField);
+ var kernel = new SimulationKernel(new SimRandom(42UL));
+ kernel.RegisterSystem(economy);
+ kernel.RegisterSystem(Construction);
+ kernel.Start();
+ Assert.That(
+ Construction.PlaceCompletedBuilding(Slot, DefHQAlliance, 4, 4).IsValid,
+ Is.True, "canonical-style start HQ anchor at footprint origin (4,4)");
+ }
+ }
+
+ [Test]
+ public void InfluenceAndSpacing_AreNecessaryForAcceptedPlacement()
+ {
+ var f = new Fixture();
+ int size = ConstructionSystem.GridSize;
+ int footprint = SimDefinitions.BuildingFootprintCells;
+ for (int y = 0; y + footprint <= size; y += 7)
+ {
+ for (int x = 0; x + footprint <= size; x += 7)
+ {
+ if (f.Construction.IsInsideBuildInfluence(Slot, x, y)
+ && f.Construction.HasMinimumBuildingSpacing(x, y))
+ {
+ continue;
+ }
+ Assert.That(
+ f.Construction.ValidatePlacement(Slot, DefPowerAlliance, x, y),
+ Is.Not.EqualTo(CommandResultCode.Applied),
+ $"({x},{y}): a cell either overlay read rejects must never validate");
+ }
+ }
+ }
+
+ [Test]
+ public void TheTwoReads_DistinguishOutsideFromInsideButSpacingBlocked()
+ {
+ var f = new Fixture();
+
+ // (20,20): far from the only anchor — outside the zone, spacing fine.
+ Assert.That(f.Construction.IsInsideBuildInfluence(Slot, 20, 20), Is.False, "outside the zone");
+ Assert.That(f.Construction.HasMinimumBuildingSpacing(20, 20), Is.True, "far from every footprint");
+
+ // (7,4): one cell from the HQ footprint — inside the zone, spacing-blocked.
+ Assert.That(f.Construction.IsInsideBuildInfluence(Slot, 7, 4), Is.True, "inside the zone");
+ Assert.That(f.Construction.HasMinimumBuildingSpacing(7, 4), Is.False, "too close to the HQ footprint");
+ Assert.That(
+ f.Construction.ValidatePlacement(Slot, DefPowerAlliance, 7, 4),
+ Is.Not.EqualTo(CommandResultCode.Applied),
+ "spacing-blocked stays rejected — the state the second tint exists for");
+
+ // (13,13): inside the zone, spacing kept — and the full validator
+ // agrees (no fields registered, open cost field, prerequisite-free
+ // Power definition, so nothing else stands in the way).
+ Assert.That(f.Construction.IsInsideBuildInfluence(Slot, 13, 13), Is.True, "inside the zone");
+ Assert.That(f.Construction.HasMinimumBuildingSpacing(13, 13), Is.True, "clear of every footprint");
+ Assert.That(
+ f.Construction.ValidatePlacement(Slot, DefPowerAlliance, 13, 13),
+ Is.EqualTo(CommandResultCode.Applied),
+ "inside + unblocked validates");
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs.meta b/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs.meta
new file mode 100644
index 0000000..d2a89e3
--- /dev/null
+++ b/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7485d2da23c6489fabcb3e76baf78f8f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Editor/BootstrapSceneGenerator.cs b/Assets/_Project/Editor/BootstrapSceneGenerator.cs
index cbcf7c4..d153788 100644
--- a/Assets/_Project/Editor/BootstrapSceneGenerator.cs
+++ b/Assets/_Project/Editor/BootstrapSceneGenerator.cs
@@ -333,6 +333,13 @@ private static GameObject CreateUiObject(MatchRunner runner, Camera camera)
PlacementGhostView ghost = uiObject.AddComponent();
WireReference(ghost, "_input", input);
+ // #91: the build-zone overlay — asks the construction system's
+ // own placement reads, so only the data source (runner) and the
+ // visibility state (input) are wired here.
+ BuildZoneOverlayView buildZone = uiObject.AddComponent();
+ WireReference(buildZone, "_runner", runner);
+ WireReference(buildZone, "_input", input);
+
ConstructionSiteMarkerView siteMarkers = uiObject.AddComponent();
WireReference(siteMarkers, "_runner", runner);
diff --git a/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs b/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs
new file mode 100644
index 0000000..cb9d998
--- /dev/null
+++ b/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs
@@ -0,0 +1,209 @@
+// Graybox throwaway. Legacy Input + OnGUI. Does NOT satisfy G2/G4 UI criteria
+// (see docs/production/MVPRecoveryPlan.md). Replaced when the new Input System and the real UI land.
+using UnityEngine;
+using Nova.Gameplay.Match;
+using Nova.Simulation.CommandsV1;
+using Nova.Simulation.Construction;
+using Nova.Simulation.Definitions;
+
+namespace Nova.Presentation.UI
+{
+ ///
+ /// The build-zone overlay (issue #91, test report T-01): one flat quad
+ /// over the whole map, textured per cell from the construction system's
+ /// OWN placement reads, so the player can finally SEE the rule that
+ /// otherwise only speaks through rejections. Two states, painted
+ /// differently on purpose: a footprint origin inside the build influence
+ /// and clear of every other footprint reads as a green wash, an origin
+ /// inside the influence but rejected by the minimum building distance
+ /// reads orange — "in the zone but blocked" is a different statement
+ /// than "outside the zone" (no tint), and confusing the two was exactly
+ /// the test-report complaint about
+ /// .
+ ///
+ /// THE RULE IS ASKED, NEVER REBUILT: each texel is the answer of
+ /// and
+ /// for a 3x3
+ /// footprint ORIGIN at that cell — the same two reads
+ /// consumes, made
+ /// public for exactly this caller. The overlay holds no radius constant
+ /// and no anchor list of its own, so when D-108 re-opens the anchor
+ /// list the picture follows the simulation on its own instead of
+ /// silently going stale. Terrain walkability and field spacing are
+ /// deliberately NOT painted: field spacing is role-dependent (the
+ /// Refinery inverts it) and the placement ghost already gives the full
+ /// per-position verdict at the cursor. Texel (x, y) answers for a
+ /// footprint origin at cell (x, y); the ghost's origin sits one cell
+ /// south-west of the cursor cell, which is exactly the value the LMB
+ /// click validates.
+ ///
+ ///
+ /// VISIBILITY: shown while
+ /// (the ghost is armed and the zone is the missing context) or while
+ /// pinned by the O toggle (),
+ /// so the base reach is readable without a build intent. Hidden when no
+ /// match is live. Pure presentation in the sense of the
+ /// FogOfWarOverlayView boundary: no collider, no input handling, no
+ /// simulation write — the mask is read, pixels are written, Apply().
+ ///
+ ///
+ /// CADENCE AND MAPPING: same one-quad-one-texture approach as the fog
+ /// overlay (CreateFlatQuad, u -> world x, v -> world z, texel =
+ /// cell one-to-one, no flip). A full 128x128 repaint runs both cell
+ /// reads, each a scan over the placement tables — cheap but not free —
+ /// so the texture refreshes at a fixed cadence while visible
+ /// (construction state only changes on tick events) and immediately on
+ /// show, instead of repainting 16 KiB of pixels per frame. The quad
+ /// sits above the fog sheet (0.04) and below the lowest HUD marker
+ /// (0.06). FilterMode.Point, not bilinear: the two states must stay
+ /// crisp per cell — blending green into orange at the boundary would
+ /// invent a third colour that means nothing.
+ ///
+ ///
+ [DisallowMultipleComponent]
+ public sealed class BuildZoneOverlayView : MonoBehaviour
+ {
+ [Header("Wiring (scene generator)")]
+ [SerializeField] private MatchRunner _runner;
+ [SerializeField] private RtsDeviceInput _input;
+
+ [Header("Presentation")]
+ [Tooltip("Tint of a footprint origin inside the build influence and clear of the minimum building distance.")]
+ [SerializeField] private Color32 _buildableColor = new Color32(70, 230, 90, 26);
+ [Tooltip("Tint of a footprint origin inside the build influence but blocked by the minimum building distance.")]
+ [SerializeField] private Color32 _spacingBlockedColor = new Color32(255, 150, 40, 44);
+ [Tooltip("World height of the overlay quad: above the fog sheet (0.04) and below the lowest HUD marker (0.06).")]
+ [SerializeField] private float _overlayHeight = 0.05f;
+ [Tooltip("Seconds between full repaints while the overlay is visible.")]
+ [SerializeField, Range(0.05f, 2f)] private float _repaintIntervalSeconds = 0.25f;
+
+ private static readonly Color32 Clear = new Color32(0, 0, 0, 0);
+
+ private Texture2D _texture;
+ private Color32[] _pixels;
+ private Material _material;
+ private GameObject _quad;
+ private float _nextRepaintTime;
+
+ private void Awake()
+ {
+ if (_runner == null) _runner = FindAnyObjectByType();
+ if (_input == null) _input = FindAnyObjectByType();
+ }
+
+ private void LateUpdate()
+ {
+ // The systems are re-read every frame: a new match replaces them,
+ // and a stale reference would paint a dead match's zone.
+ ConstructionSystem construction = _runner != null ? _runner.Construction : null;
+ MatchSession session = _runner != null ? _runner.Session : null;
+ bool wanted = construction != null && session != null && _input != null
+ && (_input.PlacementModeActive || _input.BuildZoneOverlayPinned);
+ if (!wanted)
+ {
+ SetQuadActive(false);
+ // The next show repaints immediately, not one interval late.
+ _nextRepaintTime = 0f;
+ return;
+ }
+
+ EnsureResources();
+ SetQuadActive(true);
+ if (Time.time < _nextRepaintTime) return;
+ _nextRepaintTime = Time.time + _repaintIntervalSeconds;
+ Repaint(construction, session.LocalSlot);
+ }
+
+ ///
+ /// One texel per cell: outside the build influence stays clear,
+ /// inside + minimum distance kept gets the buildable tint, inside +
+ /// too close to an existing footprint gets the blocked tint. Origins
+ /// whose 3x3 footprint would leave the map stay clear as well —
+ /// painting them buildable would promise a placement the validator
+ /// rejects on bounds alone (map geometry, not the zone rule).
+ ///
+ private void Repaint(ConstructionSystem construction, byte viewerSlot)
+ {
+ int size = ConstructionSystem.GridSize;
+ int lastOrigin = size - SimDefinitions.BuildingFootprintCells;
+
+ for (int y = 0; y < size; y++)
+ {
+ int row = y * size;
+ for (int x = 0; x < size; x++)
+ {
+ Color32 color = Clear;
+ if (x <= lastOrigin && y <= lastOrigin
+ && construction.IsInsideBuildInfluence(viewerSlot, x, y))
+ {
+ color = construction.HasMinimumBuildingSpacing(x, y)
+ ? _buildableColor
+ : _spacingBlockedColor;
+ }
+ _pixels[row + x] = color;
+ }
+ }
+
+ _texture.SetPixels32(_pixels);
+ _texture.Apply();
+ }
+
+ ///
+ /// Builds texture, material and quad lazily on the first show.
+ /// Everything is runtime-generated and HideAndDontSave — the slice
+ /// forbids hand-made material assets (GroundMarkerVisuals /
+ /// FogOfWarOverlayView convention).
+ ///
+ private void EnsureResources()
+ {
+ if (_texture == null)
+ {
+ int size = ConstructionSystem.GridSize;
+ _texture = new Texture2D(size, size, TextureFormat.RGBA32, mipChain: false)
+ {
+ hideFlags = HideFlags.HideAndDontSave,
+ filterMode = FilterMode.Point,
+ wrapMode = TextureWrapMode.Clamp
+ };
+ _pixels = new Color32[size * size];
+ }
+
+ if (_material == null)
+ {
+ Shader shader = Shader.Find("Sprites/Default");
+ if (shader == null) shader = Shader.Find("Universal Render Pipeline/Unlit");
+ _material = new Material(shader)
+ {
+ hideFlags = HideFlags.HideAndDontSave,
+ mainTexture = _texture
+ };
+ }
+
+ if (_quad == null)
+ {
+ _quad = GroundMarkerVisuals.CreateFlatQuad("BuildZoneOverlay", transform);
+ _quad.GetComponent().sharedMaterial = _material;
+ float size = ConstructionSystem.GridSize;
+ _quad.transform.position = new Vector3(size * 0.5f, _overlayHeight, size * 0.5f);
+ _quad.transform.localScale = new Vector3(size, size, 1f);
+ }
+ }
+
+ private void SetQuadActive(bool active)
+ {
+ if (_quad != null && _quad.activeSelf != active)
+ {
+ _quad.SetActive(active);
+ }
+ }
+
+ private void OnDestroy()
+ {
+ if (_texture != null) Destroy(_texture);
+ if (_material != null) Destroy(_material);
+ // The quad is a child GameObject and dies with the UI object; the
+ // shared GroundMarkerVisuals assets are untouched (the overlay
+ // runs on its own material).
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs.meta b/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs.meta
new file mode 100644
index 0000000..120b24a
--- /dev/null
+++ b/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 60b14ad0059d4ecbb3bb0f16e8f28aaf
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs
index 5287888..51b9443 100644
--- a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs
+++ b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs
@@ -168,6 +168,11 @@ private enum PendingOrder
private int _placementOriginY;
private bool _placementValid;
+ // Build-zone overlay pin (O key, #91): pure view state read by
+ // BuildZoneOverlayView — placement mode shows the overlay regardless,
+ // the pin keeps it visible without a build intent.
+ private bool _buildZoneOverlayPinned;
+
// Order target-pick mode state (command card): the armed order whose
// target the next LMB world click resolves. Mutually exclusive with
// placement mode (one gesture owns the next click).
@@ -215,6 +220,14 @@ private enum PendingOrder
/// True while a building placement ghost is armed (build bar click or building hotkey).
public bool PlacementModeActive => _placementMode;
+ ///
+ /// True while the build-zone overlay is pinned visible by the O key
+ /// (placement mode shows it regardless). Read by
+ /// ; pure view state, no command
+ /// and no simulation read is involved in the toggle itself.
+ ///
+ public bool BuildZoneOverlayPinned => _buildZoneOverlayPinned;
+
/// Definition the armed placement ghost currently carries.
public ushort PlacementDefId => _placementDefId;
@@ -406,7 +419,8 @@ private void Awake()
_legend =
"LMB click/drag select | RMB move — with an own producer building selected: set its rally point | S stop | " +
"A attack enemy under cursor (else plain move; armed units auto-acquire visible in-range enemies, D-087) | " +
- "H harvest nearest field | R return cargo | P pause/resume (local match only)\n" +
+ "H harvest nearest field | R return cargo | P pause/resume (local match only) | " +
+ "O build zone overlay on/off (shows on its own while a placement ghost is armed)\n" +
"Build (build bar below or hotkey — a ghost follows the cursor; LMB place | RMB/ESC cancel): " +
$"B {_buildingDefId} | Shift+B {_altBuildingDefId} | C {_storageDefId} | V {_vehicleFactoryDefId} | " +
$"T {_researchLabDefId} | G {_radarDefId} | F {_defensePlatformDefId} | Y {_refineryDefId}\n" +
@@ -425,6 +439,7 @@ private void Update()
Vector2 mouse = Input.mousePosition;
UpdatePlacementHover(mouse);
+ HandleBuildZoneOverlayToggle();
UpdateHarvesterEscort();
HudPointerLink.Publish(IsPointerOverHud(mouse));
HandleSelection(mouse);
@@ -502,6 +517,22 @@ private void UpdatePlacementHover(Vector2 mouse)
slot, _placementDefId, _placementOriginX, _placementOriginY) == CommandResultCode.Applied;
}
+ ///
+ /// The O key pins the build-zone overlay on/off (#91). Handled here,
+ /// not in HandleOrders, so the toggle stays live while a placement
+ /// ghost or an order pick is armed — the overlay shows during
+ /// placement anyway, and the pin simply survives the placement. Pure
+ /// view state: no command, no simulation read.
+ ///
+ private void HandleBuildZoneOverlayToggle()
+ {
+ if (!Input.GetKeyDown(KeyCode.O)) return;
+ _buildZoneOverlayPinned = !_buildZoneOverlayPinned;
+ _lastCommandStatus = _buildZoneOverlayPinned
+ ? "Build zone overlay pinned on (O hides it again)"
+ : "Build zone overlay off";
+ }
+
///
/// Input flow while the ghost is armed: LMB places (through the
/// dispatcher, at the hovered footprint origin), RMB or ESC cancels,
diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs
index 044eaf5..53e0510 100644
--- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs
+++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs
@@ -1114,8 +1114,15 @@ private bool FootprintIsWalkable(int originX, int originY)
/// accepted (D-108): a chain of cheap buildings can push the build
/// zone across the map — that coupling of expansion to the economy
/// is the decided behavior, not a defect.
+ ///
+ /// THIS METHOD IS THE RULE, and it is public so that the build-zone
+ /// overlay can ASK it per cell instead of re-deriving the radius or
+ /// the anchor list on its own. That is what let the anchor list open
+ /// (D-108) without the overlay being touched at all. Pure read, no
+ /// mutation — consumed by .
+ ///
///
- private bool IsInsideBuildInfluence(byte playerSlot, int originX, int originY)
+ public bool IsInsideBuildInfluence(byte playerSlot, int originX, int originY)
{
for (int i = 0; i < MaxBuildings; i++)
{
@@ -1133,7 +1140,18 @@ private bool IsInsideBuildInfluence(byte playerSlot, int originX, int originY)
return false;
}
- private bool HasMinimumBuildingSpacing(int originX, int originY)
+ ///
+ /// The D-104 clearance read: true when a 3x3 footprint with its
+ /// origin at (originX, originY) keeps
+ /// from every site and
+ /// every completed placement (any owner). This is the check that
+ /// rejects cells INSIDE the build zone — the build-zone overlay
+ /// paints exactly the pair (IsInsideBuildInfluence,
+ /// HasMinimumBuildingSpacing) as its two distinguishable states.
+ /// Consumed by . Pure read, no
+ /// mutation.
+ ///
+ public bool HasMinimumBuildingSpacing(int originX, int originY)
{
for (int i = 0; i < MaxSites; i++)
{