Skip to content
Open
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
152 changes: 152 additions & 0 deletions Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -407,5 +407,157 @@ public void FormatFieldReserveAE_NonPositiveValuesRenderAsZero()
Assert.AreEqual("0 / 0 AE", CommandCardPresenter.FormatFieldReserveAE(0, 0));
Assert.AreEqual("0 / 9.000 AE", CommandCardPresenter.FormatFieldReserveAE(-5, 9000));
}

// ----------------------------------------------------------------
// Shared commands over a multi-selection (21.5, #88)
// ----------------------------------------------------------------

[Test]
public void GetSharedUnitCommands_PureHarvesters_KeepHarvestAndReturnCargo()
{
var presenter = new CommandCardPresenter();

CommandButtonType commands = presenter.GetSharedUnitCommands(
FactionId.Alliance, new[] { UnitRole.Harvester, UnitRole.Harvester });

Assert.AreEqual(
CommandButtonType.Move | CommandButtonType.Stop | CommandButtonType.Harvest | CommandButtonType.ReturnCargo,
commands);
}

[Test]
public void GetSharedUnitCommands_HarvesterPlusBattleTank_KeepsOnlyMoveAndStop()
{
var presenter = new CommandCardPresenter();

// The Harvester is unarmed and the BattleTank cannot harvest:
// only the shared core survives the intersection. Previously
// the lead slot alone decided this, so both orders must agree.
CommandButtonType forward = presenter.GetSharedUnitCommands(
FactionId.Alliance, new[] { UnitRole.Harvester, UnitRole.BattleTank });
CommandButtonType reversed = presenter.GetSharedUnitCommands(
FactionId.Alliance, new[] { UnitRole.BattleTank, UnitRole.Harvester });

Assert.AreEqual(CommandButtonType.Move | CommandButtonType.Stop, forward);
Assert.AreEqual(forward, reversed, "the intersection must not depend on the selection order");
}

[Test]
public void GetSharedUnitCommands_BuilderPlusHarvester_KeepsOnlyMoveAndStop()
{
var presenter = new CommandCardPresenter();

CommandButtonType commands = presenter.GetSharedUnitCommands(
FactionId.Alliance, new[] { UnitRole.Builder, UnitRole.Harvester });

Assert.AreEqual(CommandButtonType.Move | CommandButtonType.Stop, commands);
}

[Test]
public void GetSharedUnitCommands_PureBuilders_KeepRepair()
{
var presenter = new CommandCardPresenter();

CommandButtonType commands = presenter.GetSharedUnitCommands(
FactionId.Alliance, new[] { UnitRole.Builder, UnitRole.Builder });

Assert.AreEqual(CommandButtonType.Move | CommandButtonType.Stop | CommandButtonType.Repair, commands);
}

[Test]
public void GetSharedUnitCommands_BuildingRolesAreIgnoredInsteadOfWipingTheIntersection()
{
var presenter = new CommandCardPresenter();

// GetUnitCommands returns None for building roles — skipping
// them before intersecting is what keeps a mixed selection's
// buttons alive (buildings ride along as bystanders).
CommandButtonType mixed = presenter.GetSharedUnitCommands(
FactionId.Alliance, new[] { UnitRole.HQ, UnitRole.Harvester, UnitRole.Barracks });

Assert.AreEqual(
presenter.GetUnitCommands(FactionId.Alliance, UnitRole.Harvester),
mixed,
"buildings contribute no commands to a mixed selection");
Assert.AreEqual(
CommandButtonType.None,
presenter.GetSharedUnitCommands(FactionId.Alliance, new[] { UnitRole.HQ }),
"an all-building input has no unit card at all");
}

// ----------------------------------------------------------------
// Selection breakdown (21.5, #88)
// ----------------------------------------------------------------

[Test]
public void SummarizeSelection_GroupsByRoleInFirstOccurrenceOrderWithHpSums()
{
var entities = new EntityManager(8);
EntityId leadTank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(3), maxHealth: 240, role: UnitRole.LightTank);
EntityId harvester = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(3), maxHealth: 100, role: UnitRole.Harvester);
EntityId secondTank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(3), SimFixed.FromInt(3)), SimFixed.FromInt(3), maxHealth: 240, role: UnitRole.LightTank);
entities.GetUnitRef(leadTank).CurrentHealth = 120;
entities.GetUnitRef(secondTank).CurrentHealth = 60;

var presenter = new CommandCardPresenter();
var groups = new SelectionGroup[4];
int count = presenter.SummarizeSelection(new[] { leadTank, harvester, secondTank }, entities, groups);

Assert.AreEqual(2, count);
Assert.AreEqual(UnitRole.LightTank, groups[0].Role, "the lead type stays the top row");
Assert.AreEqual(2, groups[0].Count);
Assert.AreEqual(180, groups[0].CurrentHealthSum, "summed, not averaged");
Assert.AreEqual(480, groups[0].MaxHealthSum);
Assert.AreEqual(UnitRole.Harvester, groups[1].Role);
Assert.AreEqual(1, groups[1].Count);
Assert.AreEqual(100, groups[1].CurrentHealthSum);
}

[Test]
public void SummarizeSelection_StaleHandlesAreSkippedSilently()
{
var entities = new EntityManager(4);
EntityId living = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(3), role: UnitRole.Harvester);
EntityId stale = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(3), role: UnitRole.Builder);
entities.DespawnUnit(stale); // the version bump invalidates the old handle

var presenter = new CommandCardPresenter();
var groups = new SelectionGroup[4];
int count = presenter.SummarizeSelection(new[] { living, stale }, entities, groups);

Assert.AreEqual(1, count);
Assert.AreEqual(UnitRole.Harvester, groups[0].Role);
Assert.AreEqual(1, groups[0].Count);
Assert.AreEqual(0, presenter.SummarizeSelection(new[] { living }, null, groups), "no store, no rows");
}

[Test]
public void SummarizeSelection_BuildingsGetTheirOwnGroups()
{
var entities = new EntityManager(4);
EntityId hq = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(0), role: UnitRole.HQ);
EntityId tank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(3), role: UnitRole.LightTank);

var presenter = new CommandCardPresenter();
var groups = new SelectionGroup[4];
int count = presenter.SummarizeSelection(new[] { hq, tank }, entities, groups);

Assert.AreEqual(2, count);
Assert.AreEqual(UnitRole.HQ, groups[0].Role, "first occurrence — the building lead stays on top");
Assert.AreEqual(1, groups[0].Count);
Assert.AreEqual(UnitRole.LightTank, groups[1].Role);
Assert.AreEqual(1, groups[1].Count);
}

[Test]
public void FormatSelectionGroup_UnitsShowCountAndHpSums_BuildingsStayBystanders()
{
// The sprint's own example row.
var tankGroup = new SelectionGroup { Role = UnitRole.LightTank, Count = 2, CurrentHealthSum = 180, MaxHealthSum = 240 };
Assert.AreEqual("2× Lynx — 180/240 HP", CommandCardPresenter.FormatSelectionGroup(FactionId.Alliance, tankGroup));

var buildingGroup = new SelectionGroup { Role = UnitRole.HQ, Count = 1, CurrentHealthSum = 900, MaxHealthSum = 1000 };
Assert.AreEqual("1× Hauptquartier — Gebäude", CommandCardPresenter.FormatSelectionGroup(FactionId.Alliance, buildingGroup));
}
}
}
115 changes: 114 additions & 1 deletion Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ public enum CommandButtonType
InstallDefenseModule = 1 << 9,
}

/// <summary>
/// One row of the selection breakdown (21.5, #88): all selected entities
/// sharing one role, with their count and summed HP. The group order
/// follows the FIRST OCCURRENCE in the selection — the selection order
/// is stable, so the lead type stays the top row. Buildings get their
/// own groups; they contribute no commands
/// (<see cref="CommandCardPresenter.GetSharedUnitCommands"/>).
/// </summary>
public struct SelectionGroup
{
public UnitRole Role;
public int Count;
public int CurrentHealthSum;
public int MaxHealthSum;
}

/// <summary>
/// Why a production button is blocked, in the sim's own validation order
/// (mirror of <see cref="ProductionSystem.ValidateQueueUnit"/>): the T2
Expand Down Expand Up @@ -96,7 +112,9 @@ public enum BuildingRepairBlocker
/// is returned as PRESENT for the DefensePlatform so the HUD can show it
/// deliberately disabled — the schema-v1 kind exists but the sim rejects
/// it with RejectedPrerequisitesNotMet in this slice (defense modules are
/// G2/G4 content), so it must never be dispatched.
/// G2/G4 content), so it must never be dispatched. A MULTI-selection
/// intersects these per-role sets instead of taking the lead's word
/// (21.5, #88, <see cref="GetSharedUnitCommands"/>).
/// </para>
/// <para>
/// NOT REPRESENTABLE in schema v1 (open design questions, deliberately
Expand Down Expand Up @@ -158,6 +176,101 @@ public CommandButtonType GetUnitCommands(FactionId faction, UnitRole leadRole)
return commands;
}

/// <summary>
/// The unit card's buttons for a MULTI-selection (21.5, #88): the
/// intersection of <see cref="GetUnitCommands"/> over every MOBILE
/// role in the selection — a command is only offered when it is
/// meaningful for ALL selected units, so the card stops depending
/// on the selection's order (previously the lead unit alone
/// decided). Building roles are SKIPPED before intersecting:
/// <see cref="GetUnitCommands"/> returns
/// <see cref="CommandButtonType.None"/> for them, which would wipe
/// the intersection — in a mixed selection buildings ride along as
/// bystanders (the input discipline of RtsDeviceInput's lead-producer
/// rule) and contribute no commands.
/// <para>
/// Consequences, both deliberate (the sprint's reading: only
/// commands that hold for everyone): Harvest/ReturnCargo appear only
/// on a PURE Harvester selection — which also retires the dead
/// Harvest button on mixed selections, the executor rejects Harvest
/// for non-Harvesters anyway — and Repair only on a pure Builder
/// selection. An input without any mobile role yields
/// <see cref="CommandButtonType.None"/>.
/// </para>
/// </summary>
public CommandButtonType GetSharedUnitCommands(FactionId faction, ReadOnlySpan<UnitRole> roles)
{
CommandButtonType shared = CommandButtonType.None;
bool hasMobileRole = false;
for (int i = 0; i < roles.Length; i++)
{
if (SimDefinitions.IsBuildingRole(roles[i])) continue; // bystander, no command vote
CommandButtonType roleCommands = GetUnitCommands(faction, roles[i]);
shared = hasMobileRole ? shared & roleCommands : roleCommands;
hasMobileRole = true;
}
return hasMobileRole ? shared : CommandButtonType.None;
}

/// <summary>
/// Groups a selection by role for the unit card's breakdown rows
/// (21.5, #88): count and summed current/max HP per role, in
/// first-occurrence order (the selection order is stable, so the
/// lead type stays the top row). Stale handles are skipped silently
/// via the <see cref="EntityManager.TryGetUnit"/> miss. Returns the
/// number of groups written into <paramref name="destination"/>
/// (capped at its length).
/// </summary>
public int SummarizeSelection(ReadOnlySpan<EntityId> selection, EntityManager entities, SelectionGroup[] destination)
{
if (destination == null) throw new ArgumentNullException(nameof(destination));
if (entities == null) return 0;

int groupCount = 0;
for (int i = 0; i < selection.Length; i++)
{
if (!entities.TryGetUnit(selection[i], out UnitState unit)) continue; // stale handle

int groupIndex = -1;
for (int g = 0; g < groupCount; g++)
{
if (destination[g].Role == unit.Role)
{
groupIndex = g;
break;
}
}
if (groupIndex < 0)
{
if (groupCount >= destination.Length) continue; // count what fits, never overflow
groupIndex = groupCount++;
destination[groupIndex] = new SelectionGroup { Role = unit.Role };
}

SelectionGroup group = destination[groupIndex];
group.Count++;
group.CurrentHealthSum += unit.CurrentHealth;
group.MaxHealthSum += unit.MaxHealth;
destination[groupIndex] = group;
}
return groupCount;
}

/// <summary>
/// One breakdown row of the unit card (21.5, #88): mobile roles as
/// "2× Lynx — 180/240 HP" (the group's summed HP), buildings as
/// "1× Hauptquartier — Gebäude" — bystanders carry no HP line, the
/// marker is their whole statement on a unit card.
/// </summary>
public static string FormatSelectionGroup(FactionId faction, in SelectionGroup group)
{
if (SimDefinitions.IsBuildingRole(group.Role))
{
return $"{group.Count}× {BuildingDisplayName(group.Role)} — Gebäude";
}
return $"{group.Count}× {UnitDisplayName(faction, group.Role)} — {group.CurrentHealthSum}/{group.MaxHealthSum} HP";
}

/// <summary>The command buttons of a CONSTRUCTION SITE (definition role with an active site-register row): only cancelling is meaningful.</summary>
public CommandButtonType GetSiteCommands()
{
Expand Down
Loading
Loading