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
32 changes: 24 additions & 8 deletions Assets/Tests/EditMode/AI/SkirmishAiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public sealed class SkirmishAiTests

/// <summary>
/// End-to-end tick budget: this suite's deterministic match decides
/// at tick 2242, so 6.000 ticks is a ~2.7x margin — comfortably sane,
/// at tick 2705, so 6.000 ticks is a ~2.2x margin — comfortably sane,
/// and exact because the whole loop is deterministic.
/// </summary>
private const int EndToEndBudgetTicks = 6000;
Expand Down Expand Up @@ -270,16 +270,31 @@ private static int MinCombatCellX(AiHost host, byte slot)
// ----------------------------------------------------------------

[Test]
public void SkirmishAi_PlacesRefineryThenBarracks_ThroughTheSealedCommandPath()
public void SkirmishAi_PlacesRefineryPowerThenBarracks_ThroughTheSealedCommandPath()
{
AiHost host = BuildMatch(Seed);

host.Run(800);
uint refineryTick = 0;
uint powerTick = 0;
uint barracksTick = 0;
for (int i = 0; i < 1000 && barracksTick == 0; i++)
{
host.Step();
uint tick = host.Kernel.CurrentTick.Value;
if (refineryTick == 0 && host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Refinery)) refineryTick = tick;
if (powerTick == 0 && host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Power)) powerTick = tick;
if (barracksTick == 0 && host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Barracks)) barracksTick = tick;
}

Assert.That(host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Refinery), Is.True,
"the AI must place and complete its Refinery (D-077: no prerequisite) through PlaceBuilding intents");
Assert.That(host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Barracks), Is.True,
"the AI must follow up with the Barracks once the Refinery stands");
Assert.Multiple(() =>
{
Assert.That(refineryTick, Is.GreaterThan(0u),
"the AI must place and complete its Refinery (D-077: no prerequisite) through PlaceBuilding intents");
Assert.That(powerTick, Is.GreaterThan(refineryTick),
"D-103 requires the AI to complete a Power plant after the Refinery and before its Barracks");
Assert.That(barracksTick, Is.GreaterThan(powerTick),
"the AI must complete the Barracks only after its required Power plant stands");
});
Assert.That(host.Construction.HasFinishedBuilding(HumanSlot, UnitRole.Refinery), Is.False,
"slot 0 is the passive fixture: nobody issues orders for it");

Expand All @@ -298,12 +313,13 @@ public void SkirmishAi_DefinitionRoleSite_DoesNotCountAsCompletedOrAdvanceBuildO
// The tick-20 decision submits the Refinery, tick 21 creates its
// site, and tick 40 is the first decision that must classify that
// definition-role entity through the site register. A bare role
// check queues a second (Barracks) site for tick 41.
// check queues a second (Power) site for tick 41 under D-103.
host.Run(41);

Assert.That(host.Construction.SiteCount, Is.EqualTo(1),
"an unfinished Refinery is the active build, not a completed producer that unlocks Barracks");
Assert.That(host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Refinery), Is.False);
Assert.That(host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Power), Is.False);
Assert.That(host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Barracks), Is.False);

UnitState[] units = host.Entities.RawUnits;
Expand Down
129 changes: 100 additions & 29 deletions Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Assets/Tests/EditMode/Simulation/EconomySystemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,9 @@ public void CapacityFor_CountsCompletedStorage_AndExcludesSites()
kernel.Start();
Assert.That(construction.PlaceCompletedBuilding(
0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True);
Assert.That(construction.PlaceCompletedBuilding(
0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 36, 40).IsValid, Is.True,
"the completed Refinery satisfies the Storage prerequisite");
kernel.StepTick(); // commit the grid (30 provided) for the placement power rule

// A storage SITE holds nothing yet.
Expand Down
12 changes: 7 additions & 5 deletions Assets/Tests/EditMode/Simulation/VictorySystemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ public class VictorySystemTests
private const int Capacity = 64;
private const ushort MapSize = 64;

/// <summary>Power plant / Barracks definition ids (SimDefinitions MS-1 table).</summary>
/// <summary>Power plant / DefensePlatform definition ids (SimDefinitions MS-1 table).</summary>
private const ushort DefPower = 5;
private const ushort DefBarracks = 7;
private const ushort DefDefensePlatform = 11;

/// <summary>
/// Minimal canonical host: the systems the victory contract actually
Expand Down Expand Up @@ -401,13 +401,15 @@ public void ConstructionSite_CountsAsBuilding_AndKeepsTheSideAlive()
{
TestHost host = NewHost();

// Slot 0 gets a real construction site: power provider + builder
// + credits are the placement prerequisites.
// Slot 0 gets a real DefensePlatform site: power provider + builder
// + credits are the placement prerequisites. It deliberately has
// no HQ, so D-077's separate last-HQ defeat trigger cannot mask
// the D-056 site-counting behavior under test.
EntityId power = host.Construction.PlaceCompletedBuilding(0, DefPower, 40, 40);
Assert.That(power.IsValid, Is.True, "power provider");
EntityId builder = host.SpawnUnit(0, 19, 20, UnitRole.Builder);
host.Step(1);
Assert.That(host.Construction.TryPlaceBuilding(0, DefBarracks, 20, 20), Is.True, "Barracks site");
Assert.That(host.Construction.TryPlaceBuilding(0, DefDefensePlatform, 20, 20), Is.True, "DefensePlatform site");
Assert.That(host.Construction.SiteCount, Is.EqualTo(1));

// Slot 1 is the opponent that keeps the match two-sided.
Expand Down
21 changes: 19 additions & 2 deletions Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,23 @@ public void DefinitionsHash64_IsStable_CoversBothFactions_AndIsNotAStub()
"the last Legion unit row is covered");
}

[Test]
public void DefinitionsHash64_ChangesWhenPrerequisiteMaskChanges()
{
ulong canonical = SimDefinitions.ComputeDefinitionsHash64();
var buildings = SimDefinitions.AllBuildings.ToArray();
SimBuildingDefinition source = buildings[0];
buildings[0] = new SimBuildingDefinition(
source.DefinitionId, source.Faction, source.Role,
source.CostAE, source.BuildTicks, source.PowerProvided, source.PowerRequired,
source.PrerequisiteRoles | UnitRoleMask.Power, source.MaxHealth,
source.ArmorClass, source.DamageType, source.AttackDamage,
source.AttackRangeTiles, source.AttackCooldownTicks);

Assert.That(SimDefinitions.ComputeDefinitionsHash64(buildings, SimDefinitions.AllUnits),
Is.Not.EqualTo(canonical), "all-of prerequisite bits are fingerprint-covered");
}

[Test]
public void DefinitionsHash64_ChangesWhenAnyWeaponValueChanges()
{
Expand Down Expand Up @@ -264,7 +281,7 @@ public void DefinitionsHash64_ChangesWhenAnyWeaponValueChanges()
buildings[i] = new SimBuildingDefinition(
buildings[i].DefinitionId, buildings[i].Faction, buildings[i].Role,
buildings[i].CostAE, buildings[i].BuildTicks, buildings[i].PowerProvided, buildings[i].PowerRequired,
buildings[i].HasPrerequisite, buildings[i].PrerequisiteRole, buildings[i].MaxHealth,
buildings[i].PrerequisiteRoles, buildings[i].MaxHealth,
buildings[i].ArmorClass, buildings[i].DamageType,
attackDamage: buildings[i].AttackDamage + 1, buildings[i].AttackRangeTiles, buildings[i].AttackCooldownTicks);
}
Expand All @@ -280,7 +297,7 @@ private static ulong HashWithMutatedBuilding(int index)
buildings[index].DefinitionId, buildings[index].Faction, buildings[index].Role,
costAE: buildings[index].CostAE + 1, buildings[index].BuildTicks,
buildings[index].PowerProvided, buildings[index].PowerRequired,
buildings[index].HasPrerequisite, buildings[index].PrerequisiteRole, buildings[index].MaxHealth,
buildings[index].PrerequisiteRoles, buildings[index].MaxHealth,
buildings[index].ArmorClass, buildings[index].DamageType,
buildings[index].AttackDamage, buildings[index].AttackRangeTiles, buildings[index].AttackCooldownTicks);
return SimDefinitions.ComputeDefinitionsHash64(buildings, SimDefinitions.AllUnits);
Expand Down
10 changes: 9 additions & 1 deletion Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,16 @@ public static class AiBehaviorId
/// a copied number, and goes red either way.
/// </para>
/// </para>
/// <para>
/// r7 keeps the D-077 strategic opening but makes its prerequisite
/// handoff explicit: after the Refinery, the AI completes the Power
/// plant required by D-103 before attempting its Barracks. The old
/// margin-only rule happened to do that for Alliance, but Legion's
/// 15-point margin covered the Barracks' 10-point draw and therefore
/// retried an illegal placement forever once the all-of gate shipped.
/// </para>
/// </summary>
public const int Revision = 6;
public const int Revision = 7;

/// <summary>
/// Hash over every value of the shipped profile. Domain-separated like
Expand Down
4 changes: 3 additions & 1 deletion Assets/_Project/Scripts/AI.Data/AiProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ namespace Nova.AI.Data
/// Free power kept in reserve: a power-drawing building is placed only
/// while the committed margin covers its draw plus this reserve. 0
/// means "place a Power plant when the margin would go negative" — the
/// D-077 opening rule the game ships with.
/// D-077 margin rule the game ships with. Independently, D-103 forces
/// a Power plant whenever the planned building names Power as a still
/// missing prerequisite.
/// </summary>
public int PowerReserve { get; }

Expand Down
26 changes: 16 additions & 10 deletions Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ namespace Nova.AI
/// <para>
/// DECISION LOOP (fixed cadence <see cref="DecisionTickInterval"/> = 20
/// ticks = 2.0 s, ascending-index scans only, no PRNG): (1) build order —
/// Refinery first (no prerequisite since D-077), then Barracks, one site
/// at a time, a Power plant first whenever the committed margin would
/// drop below the profile reserve, the spot picked by a deterministic
/// Refinery first (no prerequisite since D-077), then the Power plant
/// required by D-103, then Barracks, one site at a time; Power also
/// preempts whenever the committed margin would drop below the profile
/// reserve, the spot picked by a deterministic
/// search validated through <see cref="ConstructionSystem.ValidatePlacement"/>
/// — the identical rules the command executor applies; (2) the Builder is
/// moved next to an unfinished site when it is out of the documented
Expand Down Expand Up @@ -293,11 +294,11 @@ private void Decide()
// slot that owns nothing is defeated anyway): stay idle.
if (hqRaw == 0) return;

// ---- (1) Build order: Refinery, then Barracks, one site at a
// time (a single Builder cannot progress two sites). A Power
// plant preempts whenever the committed margin would drop below
// the profile reserve — "when the margin would go negative" with
// the demo profile's reserve of 0. ----
// ---- (1) Build order: Refinery, required Power plant, then
// Barracks, one site at a time (a single Builder cannot progress
// two sites). Power also preempts whenever the committed margin
// would drop below the profile reserve — "when the margin would
// go negative" with the demo profile's reserve of 0. ----
if (sites.Count == 0)
{
UnitRole next = refineryRaw == 0
Expand All @@ -306,8 +307,13 @@ private void Decide()
if (next != UnitRole.Unit
&& SimDefinitions.TryGetBuilding(faction, next, out SimBuildingDefinition nextDef))
{
if (nextDef.PowerRequired > 0 && !powerCompleted
&& powerMargin < nextDef.PowerRequired + _profile.TargetPowerMargin)
UnitRoleMask missingPrerequisites = _construction.GetMissingPrerequisiteRoles(
_aiPlayerId,
nextDef.PrerequisiteRoles);
bool missingRequiredPower = (missingPrerequisites & UnitRoleMask.Power) != 0;
bool needsPowerMargin = nextDef.PowerRequired > 0
&& powerMargin < nextDef.PowerRequired + _profile.TargetPowerMargin;
if (!powerCompleted && (missingRequiredPower || needsPowerMargin))
{
next = UnitRole.Power;
}
Expand Down
42 changes: 34 additions & 8 deletions Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ namespace Nova.Presentation.UI
/// and is not wired at runtime, so SimDefinitions is the honest source:
/// the bar cannot drift from the executor. Availability mirrors the
/// executor's own rule precisely — the prerequisite check is the sim's
/// <see cref="ConstructionSystem.HasFinishedBuilding"/> and the credit
/// <see cref="ConstructionSystem.HasFinishedBuildings"/> and the credit
/// check is the balance the executor charges at placement.
/// </para>
/// <para>
Expand All @@ -64,6 +64,14 @@ public sealed class BuildMenuHud : MonoBehaviour
UnitRole.Radar, UnitRole.DefensePlatform
};

/// <summary>Stable display order for missing all-of prerequisites.</summary>
private static readonly UnitRole[] PrerequisiteDisplayOrder =
{
UnitRole.HQ, UnitRole.Power, UnitRole.Refinery, UnitRole.Storage,
UnitRole.Barracks, UnitRole.VehicleFactory, UnitRole.ResearchLab,
UnitRole.Radar, UnitRole.DefensePlatform
};

/// <summary>
/// The opening-loop hint. German, like the runbook: build a Refinery
/// (Y), produce a Harvester (Q) at it, then harvest (H).
Expand Down Expand Up @@ -338,8 +346,7 @@ private void DrawBar()
/// <summary>Entry availability, the executor's own rule: prerequisite finished (if any) and enough credits.</summary>
private static bool IsAvailable(in SimBuildingDefinition def, byte slot, long credits, ConstructionSystem construction)
{
bool prerequisiteMet = !def.HasPrerequisite
|| construction.HasFinishedBuilding(slot, def.PrerequisiteRole);
bool prerequisiteMet = construction.HasFinishedBuildings(slot, def.PrerequisiteRoles);
return prerequisiteMet && credits >= def.CostAE;
}

Expand Down Expand Up @@ -418,14 +425,33 @@ private string ButtonLabel(UnitRole role, in SimBuildingDefinition def, float bu
}

/// <summary>The hovered entry's blocker, in the executor's own check order — prerequisite first, then affordability.</summary>
private static string BlockerReason(
private string BlockerReason(
UnitRole role, in SimBuildingDefinition def, byte slot, long credits, ConstructionSystem construction)
{
bool prerequisiteMet = !def.HasPrerequisite
|| construction.HasFinishedBuilding(slot, def.PrerequisiteRole);
if (!prerequisiteMet)
UnitRoleMask missing = construction.GetMissingPrerequisiteRoles(slot, def.PrerequisiteRoles);
if (missing != UnitRoleMask.None)
{
return $"{CommandCardPresenter.BuildingDisplayName(role)}: benötigt {CommandCardPresenter.BuildingDisplayName(def.PrerequisiteRole)}";
_builder.Clear();
_builder.Append(CommandCardPresenter.BuildingDisplayName(role)).Append(": benötigt ");
bool appended = false;
UnitRoleMask remaining = missing;
for (int i = 0; i < PrerequisiteDisplayOrder.Length; i++)
{
UnitRole prerequisiteRole = PrerequisiteDisplayOrder[i];
UnitRoleMask roleMask = (UnitRoleMask)(1u << (int)prerequisiteRole);
if ((missing & roleMask) == UnitRoleMask.None) continue;

if (appended) _builder.Append(" + ");
_builder.Append(CommandCardPresenter.BuildingDisplayName(prerequisiteRole));
appended = true;
remaining &= ~roleMask;
}
if (remaining != UnitRoleMask.None)
{
if (appended) _builder.Append(" + ");
_builder.Append("unbekannte Voraussetzung 0x").Append(((uint)remaining).ToString("X8"));
}
return _builder.ToString();
}
return $"{CommandCardPresenter.BuildingDisplayName(role)}: nicht genug Aetherium";
}
Expand Down
Loading
Loading