Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 97 additions & 7 deletions Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ namespace Nova.Presentation.UI
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// DATA SOURCE: <see cref="SimDefinitions"/> — the authoritative static
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -406,14 +416,18 @@ private void DrawChromeAndStatusLine(Rect zone, Rect buttonsRect, string powerBa
/// <summary>
/// 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.
/// </summary>
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;
Expand Down Expand Up @@ -507,6 +521,82 @@ private string BlockerReason(
}
}

/// <summary>
/// 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 (<see cref="ConstructionSystem.GetPlacementDenial"/>) 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).
/// </summary>
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.";
}
}

/// <summary>
/// Hard truncation to a pixel width against the real button style:
/// drops characters until the text plus the ellipsis fits. IMGUI does
Expand Down
146 changes: 132 additions & 14 deletions Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <see cref="GetPlacementDenial"/> (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 &gt; 0 may only be
/// placed while the owner's last committed balance
/// (previous tick's phase-2 recompute) covers the additional draw:
Expand Down Expand Up @@ -487,6 +491,67 @@ private static UnitRoleMask RoleMask(UnitRole role)
// these at the target tick in the documented fixed order)
// ------------------------------------------------------------------

/// <summary>
/// The specific state-dependent reason a placement is denied, in the
/// fixed validation order of <see cref="ValidatePlacement"/> (first
/// failure wins, exactly like the validator's short-circuit). Schema
/// v1 deliberately keeps ONE <see cref="CommandResultCode"/> 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 <see cref="GetPlacementDenial"/> and words the reason;
/// nothing here mutates state or the replay.
/// </summary>
public enum PlacementDenial
{
/// <summary>Every check passed; <see cref="ValidatePlacement"/> returns Applied.</summary>
None = 0,

/// <summary>The definition id names no building row (RejectedInvalidTarget).</summary>
UnknownDefinition = 1,

/// <summary>The definition id names another faction's row (RejectedInvalidTarget).</summary>
ForeignDefinition = 2,

/// <summary>The 3x3 footprint would leave the 128x128 grid (RejectedInvalidTarget).</summary>
FootprintOutsideMap = 3,

/// <summary>A footprint cell is already occupied by a placement or site (RejectedInvalidTarget).</summary>
FootprintOccupied = 4,

/// <summary>A footprint cell sits on impassable terrain (D-104; RejectedInvalidTarget).</summary>
FootprintOnImpassableTerrain = 5,

/// <summary>
/// No own, living, COMPLETED building within
/// <see cref="BuildInfluenceRadiusCells"/> (D-108; RejectedInvalidTarget).
/// The build zone is anchored to finished buildings only — never
/// to the Builder unit and never to a construction site.
/// </summary>
OutsideBuildInfluence = 6,

/// <summary>Less than <see cref="MinimumBuildingDistanceCells"/> to another footprint or site, any owner (RejectedInvalidTarget).</summary>
TooCloseToBuilding = 7,

/// <summary>
/// The Aetherium-field geometry fails (D-104; RejectedInvalidTarget):
/// a Refinery needs a field at distance
/// <see cref="RefineryMinimumFieldDistanceCells"/>..<see cref="RefineryMaximumFieldDistanceCells"/>,
/// every other role must keep <see cref="MinimumNonRefineryFieldDistanceCells"/>.
/// </summary>
FieldSpacingViolated = 8,

/// <summary>A completed own building role of the all-of prerequisite mask is missing (D-103; RejectedPrerequisitesNotMet).</summary>
MissingPrerequisite = 9,

/// <summary>The last committed power balance does not cover the new draw (RejectedPrerequisitesNotMet).</summary>
InsufficientPower = 10,

/// <summary>The site register is full (<see cref="MaxSites"/>; RejectedPrerequisitesNotMet).</summary>
SiteCapacityReached = 11,
}

/// <summary>
/// Full state-dependent placement validation in fixed order: unknown
/// definition, foreign-faction definition, out-of-map footprint and
Expand All @@ -495,46 +560,99 @@ 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.
/// <para>
/// The result is the coarse schema-v1 mapping of
/// <see cref="GetPlacementDenial"/> — this method IS that walk mapped
/// onto <see cref="CommandResultCode"/>, 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.
/// </para>
/// </summary>
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;
}
}

/// <summary>
/// The fine-grained read of <see cref="ValidatePlacement"/>: the
/// FIRST state-dependent denial in the validator's own fixed order,
/// or <see cref="PlacementDenial.None"/> when the placement is legal.
/// Pure read, no mutation, no cost check (credits are the executor's
/// separate gate, evaluated before this walk).
/// <para>
/// 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
/// <see cref="IsInsideBuildInfluence"/> and
/// <see cref="HasMinimumBuildingSpacing"/> 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).
/// </para>
/// </summary>
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))
{
// Definition ids are faction-resolved: a slot may only place
// 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;
}

/// <summary>CancelConstruction legality: the entity must be an active site owned by the slot.</summary>
Expand Down
Loading
Loading