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
76 changes: 73 additions & 3 deletions Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using NUnit.Framework;
using Nova.Core;
using Nova.Gameplay;
using Nova.Simulation.Construction;
using Nova.Simulation.Definitions;
using Nova.Simulation.Production;
using Nova.Simulation.State;
Expand All @@ -10,8 +11,8 @@ namespace Nova.Gameplay.Tests
/// <summary>
/// Contract tests for the role-aware command-card mapping of
/// <see cref="CommandCardPresenter"/>: which buttons a selection shows
/// (presence), the blocker evaluation order that greys them
/// (availability — the mirror of the executor's own validation order),
/// (presence), the blocker evaluation priorities that grey them
/// (availability — documented separately for each surface),
/// the producer/content listing and the building-side repair-actor
/// convention. The legacy count-only overload is pinned by
/// SelectionManagerTests and deliberately not duplicated here.
Expand Down Expand Up @@ -192,7 +193,7 @@ public void GetProducibleUnits_FiltersByFaction()
}

// ----------------------------------------------------------------
// Availability evaluation (mirrors the executor's validation order)
// Availability evaluation (documented per-surface priorities)
// ----------------------------------------------------------------

[Test]
Expand Down Expand Up @@ -239,6 +240,75 @@ public void EvaluateProductionBlocker_InsufficientCreditsAndNone()
"unlocked, funded and queue space: the executor would apply");
}

[Test]
public void EvaluateBuildingPlacementBlocker_FollowsBuildMenuPriority()
{
Assert.IsTrue(SimDefinitions.TryGetBuilding(
FactionId.Alliance, UnitRole.VehicleFactory, out SimBuildingDefinition factory));

Assert.AreEqual(
BuildingPlacementBlocker.MissingPrerequisite,
CommandCardPresenter.EvaluateBuildingPlacementBlocker(
in factory, prerequisiteMet: false, credits: 0,
powerProvided: 0, powerRequired: 0, activeSiteCount: ConstructionSystem.MaxSites),
"the build menu prioritizes the actionable prerequisite chain when every blocker applies");
Assert.AreEqual(
BuildingPlacementBlocker.InsufficientCredits,
CommandCardPresenter.EvaluateBuildingPlacementBlocker(
in factory, prerequisiteMet: true, credits: factory.CostAE - 1,
powerProvided: 0, powerRequired: 0, activeSiteCount: ConstructionSystem.MaxSites),
"affordability wins over power and capacity");
Assert.AreEqual(
BuildingPlacementBlocker.InsufficientPower,
CommandCardPresenter.EvaluateBuildingPlacementBlocker(
in factory, prerequisiteMet: true, credits: factory.CostAE,
powerProvided: factory.PowerRequired - 1, powerRequired: 0,
activeSiteCount: ConstructionSystem.MaxSites),
"power wins over site capacity");
Assert.AreEqual(
BuildingPlacementBlocker.SiteCapacityReached,
CommandCardPresenter.EvaluateBuildingPlacementBlocker(
in factory, prerequisiteMet: true, credits: factory.CostAE,
powerProvided: factory.PowerRequired, powerRequired: 0,
activeSiteCount: ConstructionSystem.MaxSites),
"free power equal to the draw is sufficient, exposing the later capacity blocker");
Assert.AreEqual(
BuildingPlacementBlocker.None,
CommandCardPresenter.EvaluateBuildingPlacementBlocker(
in factory, prerequisiteMet: true, credits: factory.CostAE,
powerProvided: factory.PowerRequired, powerRequired: 0,
activeSiteCount: ConstructionSystem.MaxSites - 1));
}

[Test]
public void EvaluateBuildingPlacementBlocker_ZeroDrawNeverEnergyBlocks()
{
Assert.IsTrue(SimDefinitions.TryGetBuilding(
FactionId.Alliance, UnitRole.Power, out SimBuildingDefinition powerPlant));

Assert.AreEqual(
BuildingPlacementBlocker.None,
CommandCardPresenter.EvaluateBuildingPlacementBlocker(
in powerPlant, prerequisiteMet: true, credits: powerPlant.CostAE,
powerProvided: 0, powerRequired: 100, activeSiteCount: 0));
}

[Test]
public void PowerFormatters_NameBalanceConsequenceAndBuildingDraw()
{
Assert.AreEqual("Strom 30/20", CommandCardPresenter.FormatPowerBalance(30, 20));
Assert.AreEqual(
"Strom 20/40 · LOW POWER: Produktion ½",
CommandCardPresenter.FormatPowerBalance(20, 40));

Assert.IsTrue(SimDefinitions.TryGetBuilding(
FactionId.Alliance, UnitRole.Power, out SimBuildingDefinition powerPlant));
Assert.IsTrue(SimDefinitions.TryGetBuilding(
FactionId.Alliance, UnitRole.Refinery, out SimBuildingDefinition refinery));
Assert.AreEqual("Erzeugt +100 Strom", CommandCardPresenter.FormatBuildingPower(in powerPlant));
Assert.AreEqual("Benötigt 20 Strom", CommandCardPresenter.FormatBuildingPower(in refinery));
}

[Test]
public void EvaluateBuildingRepairBlocker_FollowsTheSimRepairRule()
{
Expand Down
29 changes: 29 additions & 0 deletions Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,35 @@ public void SealedMoveCommand_ChangesUnitStateAtTargetTick()
"the ordered unit must actually move after the target tick");
}

[Test]
public void SealedStopCommand_ClearsMovementAndAttackTarget_WithoutCombatSystem()
{
var host = TestHost.Create(Seed);
EntityId unit = host.Entities.SpawnUnit(
0, new Transform2D(SimFixed.FromFloat(10.5f), SimFixed.FromFloat(10.5f)), SimFixed.FromInt(5));
EntityId target = host.Entities.SpawnUnit(
1, new Transform2D(SimFixed.FromFloat(20.5f), SimFixed.FromFloat(20.5f)), SimFixed.FromInt(5));

ref UnitState state = ref host.Entities.GetUnitRef(unit);
state.SetTarget(new GridPos2D(30, 30));
state.AttackTarget = target;

var stop = new StopPayload(new[] { UnitCommandStateView.ToRawEntityId(unit) });
Assert.AreEqual(
CommandIngressResult.Accepted,
host.Ingress.TrySubmitIntent(CommandIntent.Create(stop), out _));

host.StepTick();

Assert.AreEqual(1, host.Kernel.LastTickResults.Count);
Assert.AreEqual(CommandResultCode.Applied, host.Kernel.LastTickResults[0].Code);
ref readonly UnitState stopped = ref host.Entities.GetUnitRef(unit);
Assert.IsFalse(stopped.IsMoving);
Assert.IsFalse(stopped.TargetGridPos.IsValid);
Assert.IsFalse(stopped.GoalGridPos.IsValid);
Assert.AreEqual(EntityId.Invalid, stopped.AttackTarget);
}

[Test]
public void StateHash_ReflectsStateMutation_AndStaysStableOnRepeat()
{
Expand Down
70 changes: 69 additions & 1 deletion Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using Nova.Core;
using Nova.Simulation.Construction;
using Nova.Simulation.Definitions;
using Nova.Simulation.Production;
using Nova.Simulation.State;
Expand All @@ -10,7 +11,8 @@ namespace Nova.Gameplay
/// The buttons one command card can show. Presence is role-driven
/// (<see cref="CommandCardPresenter"/>); whether a present button is
/// clickable is a separate, state-driven evaluation
/// (<see cref="ProductionBlocker"/>, <see cref="BuildingRepairBlocker"/>)
/// (<see cref="ProductionBlocker"/>, <see cref="BuildingPlacementBlocker"/>,
/// <see cref="BuildingRepairBlocker"/>)
/// — a greyed button must always carry its reason.
/// </summary>
[Flags]
Expand Down Expand Up @@ -44,6 +46,23 @@ public enum ProductionBlocker
InsufficientCredits = 3,
}

/// <summary>
/// Why the build bar reports a building as blocked. Before a target cell
/// exists, the HUD uses the explicit priority prerequisite, affordability,
/// free power and finally global construction-site capacity. This is a UI
/// priority, not the global CommandExecutor order (which checks credits
/// before domain validation). The UI derives the reason because schema v1
/// deliberately shares one result code between several domain cases.
/// </summary>
public enum BuildingPlacementBlocker
{
None = 0,
MissingPrerequisite = 1,
InsufficientCredits = 2,
InsufficientPower = 3,
SiteCapacityReached = 4,
}

/// <summary>
/// Why a building's repair button is blocked. The sim's repair order
/// needs a live Builder as the actor and an own COMPLETED placement that
Expand Down Expand Up @@ -202,6 +221,55 @@ public static ProductionBlocker EvaluateProductionBlocker(
return ProductionBlocker.None;
}

/// <summary>
/// First building-placement blocker in the build bar's explicit UI
/// priority. Geometry is intentionally absent: the bar has no target
/// cell until placement mode starts. This priority is not the global
/// CommandExecutor order; an energy blocker is informational and must
/// not disable entering placement mode.
/// </summary>
public static BuildingPlacementBlocker EvaluateBuildingPlacementBlocker(
in SimBuildingDefinition definition, bool prerequisiteMet, long credits,
int powerProvided, int powerRequired, int activeSiteCount)
{
if (!prerequisiteMet) return BuildingPlacementBlocker.MissingPrerequisite;
if (credits < definition.CostAE) return BuildingPlacementBlocker.InsufficientCredits;
if (definition.PowerRequired > 0
&& powerProvided - powerRequired < definition.PowerRequired)
{
return BuildingPlacementBlocker.InsufficientPower;
}
if (activeSiteCount >= ConstructionSystem.MaxSites)
{
return BuildingPlacementBlocker.SiteCapacityReached;
}
return BuildingPlacementBlocker.None;
}

/// <summary>
/// Compact live grid balance for the build bar. Low power must name
/// its gameplay consequence where the player makes build decisions.
/// </summary>
public static string FormatPowerBalance(int powerProvided, int powerRequired)
{
string balance = $"Strom {powerProvided}/{powerRequired}";
return powerRequired > powerProvided
? balance + " · LOW POWER: Produktion ½"
: balance;
}

/// <summary>Power generation or draw shown on a selected building's command card and on build-button hover.</summary>
public static string FormatBuildingPower(in SimBuildingDefinition definition)
{
if (definition.PowerProvided > 0 && definition.PowerRequired > 0)
{
return $"Erzeugt +{definition.PowerProvided} · benötigt {definition.PowerRequired} Strom";
}
if (definition.PowerProvided > 0) return $"Erzeugt +{definition.PowerProvided} Strom";
if (definition.PowerRequired > 0) return $"Benötigt {definition.PowerRequired} Strom";
return "Kein Strombedarf";
}

/// <summary>
/// First blocker of a building's repair button: an undamaged building
/// has nothing to repair (the executor rejects such an order as an
Expand Down
Loading
Loading