diff --git a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs
index d4d9451..8a8d5f8 100644
--- a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs
+++ b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs
@@ -49,7 +49,7 @@ public sealed class SkirmishAiTests
///
/// 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.
///
private const int EndToEndBudgetTicks = 6000;
@@ -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");
@@ -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;
diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs
index 61045ab..ad14e65 100644
--- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs
+++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs
@@ -149,6 +149,28 @@ public void Definitions_CoverBothFactions_WithTheDocumentedIdRule()
"the Refinery lost its Power-plant prerequisite in both factions (D-077); its draw is unchanged");
Assert.That(SimDefinitions.TryGetBuilding(FactionId.Legion, UnitRole.Refinery, out SimBuildingDefinition refineryL)
&& refineryL.PowerRequired == 15 && !refineryL.HasPrerequisite, Is.True);
+
+ var prerequisiteTable = new (UnitRole Role, UnitRoleMask Prerequisites)[]
+ {
+ (UnitRole.HQ, UnitRoleMask.None),
+ (UnitRole.Power, UnitRoleMask.HQ),
+ (UnitRole.Refinery, UnitRoleMask.None),
+ (UnitRole.Storage, UnitRoleMask.Refinery),
+ (UnitRole.Barracks, UnitRoleMask.HQ | UnitRoleMask.Power),
+ (UnitRole.VehicleFactory, UnitRoleMask.Refinery | UnitRoleMask.Barracks),
+ (UnitRole.ResearchLab, UnitRoleMask.VehicleFactory),
+ (UnitRole.Radar, UnitRoleMask.Power | UnitRoleMask.Barracks),
+ (UnitRole.DefensePlatform, UnitRoleMask.Power),
+ };
+ foreach (FactionId faction in new[] { FactionId.Alliance, FactionId.Legion })
+ {
+ foreach ((UnitRole role, UnitRoleMask prerequisites) in prerequisiteTable)
+ {
+ Assert.That(SimDefinitions.TryGetBuilding(faction, role, out SimBuildingDefinition def), Is.True);
+ Assert.That(def.PrerequisiteRoles, Is.EqualTo(prerequisites),
+ $"{faction} {role} prerequisite mask");
+ }
+ }
}
[Test]
@@ -199,6 +221,8 @@ public void ValidatePlacement_ForeignFactionDefinition_IsRejectedInvalidTarget()
// Legion one (id 24) — a known id naming unbuildable content is an
// invalid target, exactly like an unknown one.
var f = new Fixture(configure: e => e.SetSlotFaction(1, FactionId.Legion));
+ Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 36, 40).IsValid, Is.True,
+ "Legion HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(1, 22, 40, 40).IsValid, Is.True,
"Legion power provider so the power rule does not mask the faction check");
f.Step(1); // commit the balance
@@ -218,10 +242,11 @@ public void ValidatePlacement_ForeignFactionDefinition_IsRejectedInvalidTarget()
public void PlaceBuilding_LegionSlot_ChargesLegionCost_AndBuildsFaster()
{
var f = new Fixture(configure: e => e.SetSlotFaction(1, FactionId.Legion));
+ Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 36, 40).IsValid, Is.True, "Legion HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(1, 22, 40, 40).IsValid, Is.True, "Legion power provider");
f.SpawnBuilder(1, 19, 20);
f.Step(1); // commit the balance (Legion Power plant provides 80)
- Assert.That(f.Economy.GetPlayerEconomy(1).PowerProvided, Is.EqualTo(80),
+ Assert.That(f.Economy.GetPlayerEconomy(1).PowerProvided, Is.EqualTo(110),
"the power recompute is faction-resolved");
Assert.That(f.Construction.TryPlaceBuilding(1, 24, 20, 20), Is.True, "Legion Barracks def 24");
@@ -255,6 +280,7 @@ private static uint RawOfCompleted(Fixture f, byte slot, UnitRole role)
public void PlaceBuilding_ChargesExactCost_AndCreatesSiteEntity()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
f.SpawnBuilder(0, 19, 20);
f.Step(1); // commit the balance (phase-2 recompute)
@@ -298,6 +324,7 @@ public void PlaceBuilding_OccupiedOrOutOfMap_IsRejectedInvalidTarget()
var f = new Fixture();
f.SpawnBuilder(0, 19, 20);
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 20, 20).IsValid, Is.True, "Power plant at (20,20)");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True, "completed Refinery prerequisite");
f.Step(1); // commit the balance
Assert.That(f.Construction.ValidatePlacement(0, 6, 21, 21), Is.EqualTo(CommandResultCode.RejectedInvalidTarget),
@@ -311,16 +338,47 @@ public void PlaceBuilding_OccupiedOrOutOfMap_IsRejectedInvalidTarget()
}
[Test]
- public void PlaceBuilding_MissingPrerequisite_IsRejectedPrerequisitesNotMet()
+ public void PlaceBuilding_AllPrerequisitesAreRequired_AndUnknownBitsFailClosed()
{
var f = new Fixture();
f.SpawnBuilder(0, 19, 20);
- Assert.That(f.Construction.ValidatePlacement(0, 11, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet),
- "a DefensePlatform requires a completed own Power plant");
+ UnitRoleMask barracksPrerequisites = UnitRoleMask.HQ | UnitRoleMask.Power;
+ Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites),
+ Is.EqualTo(barracksPrerequisites));
+ Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet),
+ "Barracks requires both HQ and Power");
+
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True);
+ Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites),
+ Is.EqualTo(UnitRoleMask.Power), "HQ alone is insufficient");
+ Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet));
+
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True);
- f.Step(1); // commit the balance (100 provided)
- Assert.That(f.Construction.ValidatePlacement(0, 11, 20, 20), Is.EqualTo(CommandResultCode.Applied));
+ Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites), Is.EqualTo(UnitRoleMask.None));
+ Assert.That(f.Construction.HasFinishedBuildings(0, barracksPrerequisites), Is.True);
+ f.Step(1); // commit the balance (130 provided)
+ Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.Applied));
+
+ var onlyPower = new Fixture();
+ Assert.That(onlyPower.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True);
+ Assert.That(onlyPower.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites),
+ Is.EqualTo(UnitRoleMask.HQ), "Power alone is insufficient");
+
+ var foreign = new Fixture();
+ Assert.That(foreign.Construction.PlaceCompletedBuilding(1, 3, 40, 40).IsValid, Is.True);
+ Assert.That(foreign.Construction.GetMissingPrerequisiteRoles(0, UnitRoleMask.HQ),
+ Is.EqualTo(UnitRoleMask.HQ), "a foreign completed building does not satisfy the mask");
+
+ var unfinished = new Fixture(startingCredits: 3000);
+ unfinished.SpawnBuilder(0, 19, 20);
+ Assert.That(unfinished.Construction.TryPlaceBuilding(0, 3, 20, 20), Is.True, "own HQ site");
+ Assert.That(unfinished.Construction.GetMissingPrerequisiteRoles(0, UnitRoleMask.HQ),
+ Is.EqualTo(UnitRoleMask.HQ), "an own unfinished site does not satisfy the mask");
+
+ const UnitRoleMask unknownBit = (UnitRoleMask)(1u << 31);
+ Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, unknownBit), Is.EqualTo(unknownBit));
+ Assert.That(f.Construction.HasFinishedBuildings(0, unknownBit), Is.False, "unknown roles fail closed");
}
[Test]
@@ -328,15 +386,16 @@ public void PlaceBuilding_PowerRule_RequiresSufficientFreePower()
{
var f = new Fixture();
f.SpawnBuilder(0, 19, 20);
- // Committed balance: HQ 30 provided, Refinery 20 required -> 10 free.
+ // Committed balance: HQ 30 provided, completed VehicleFactory 25
+ // required -> 5 free. The factory satisfies the ResearchLab's
+ // prerequisite, so only the power gate can reject it.
Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True);
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 44, 40).IsValid, Is.True);
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 44, 40).IsValid, Is.True);
f.Step(1); // let the economy recompute the balance
- Assert.That(f.Construction.ValidatePlacement(0, 8, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet),
- "VehicleFactory draws 25 but only 10 are free");
- Assert.That(f.Construction.ValidatePlacement(0, 6, 20, 20), Is.EqualTo(CommandResultCode.Applied),
- "Storage draws 5 of the 10 free power");
+ Assert.That(f.Construction.HasFinishedBuildings(0, UnitRoleMask.VehicleFactory), Is.True);
+ Assert.That(f.Construction.ValidatePlacement(0, 9, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet),
+ "ResearchLab draws 30 but only 5 are free");
Assert.That(f.Construction.ValidatePlacement(0, 5, 60, 60), Is.EqualTo(CommandResultCode.Applied),
"power-providing buildings are exempt from the rule");
}
@@ -367,6 +426,7 @@ public void RefineryPlacement_NeedsNoPowerPlant_TheCommandPathEnforcesOnlyThePow
public void SiteProgress_RequiresBuilderInReach_PausesWhenAway()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
EntityId builder = f.SpawnBuilder(0, 60, 60); // far away
f.Step(1); // commit the balance
@@ -392,8 +452,10 @@ public void SiteProgress_RequiresBuilderInReach_PausesWhenAway()
public void SiteProgress_LowPower_ExactlyHalvesProgress()
{
var f = new Fixture();
- // Low power: a completed Refinery draws 20 with nothing provided.
+ // Low power: an HQ provides 30 while two completed Refineries draw 40.
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 48, 40).IsValid, Is.True);
Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True);
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 44, 40).IsValid, Is.True);
f.SpawnBuilder(0, 19, 20);
Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True, "Power plant def 5, 150 ticks");
f.Step(1);
@@ -417,13 +479,14 @@ public void SiteProgress_LowPower_ExactlyHalvesProgress()
Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Power),
"the plant completes after exactly 300 low-power ticks");
Assert.That(f.Construction.SiteCount, Is.EqualTo(0));
- Assert.That(f.Construction.BuildingCount, Is.EqualTo(2));
+ Assert.That(f.Construction.BuildingCount, Is.EqualTo(4));
}
[Test]
public void Completion_NormalizesLegacySiteRole_AndPowerAppliesFromNextTick()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ prerequisite");
f.SpawnBuilder(0, 19, 20);
Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True);
uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f));
@@ -439,10 +502,10 @@ public void Completion_NormalizesLegacySiteRole_AndPowerAppliesFromNextTick()
"completion normalizes legacy snapshot entities to their definition role");
Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).CurrentHealth, Is.EqualTo(400),
"completion restores full HP");
- Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(0),
+ Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(30),
"the economy ran before construction inside the completion tick");
f.Step(1);
- Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(100),
+ Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(130),
"power applies from the next economy recompute on");
}
@@ -451,7 +514,7 @@ public void ResearchLabCompletion_UnlocksT2()
{
var f = new Fixture(startingCredits: 3000);
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); // power
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 7, 44, 40).IsValid, Is.True); // barracks prerequisite
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 44, 40).IsValid, Is.True); // VehicleFactory prerequisite
f.SpawnBuilder(0, 19, 20);
f.Step(1);
@@ -678,26 +741,28 @@ public void Site_CarriesDefinitionRole_ButDrawsAndProvidesNoPower_UntilCompletio
public void PowerSite_ProvidesNothing_UntilCompletion()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True,
+ "the completed HQ satisfies the Power-plant prerequisite and provides 30 power");
f.SpawnBuilder(0, 19, 20);
f.Step(1);
Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True, "Power plant def 5 (feeds 100 completed)");
f.Step(1);
- Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(0),
- "a Power site must not power itself up mid-build");
+ Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(30),
+ "a Power site must not add to the completed-HQ baseline mid-build");
f.Step(150); // completion (150 full-power ticks)
f.Step(1); // next economy recompute
- Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(100),
- "the completed plant feeds its 100");
+ Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(130),
+ "the completed plant adds its 100 to the HQ's 30");
}
[Test]
public void CancelConstruction_Refunds75Percent_AndFreesFootprint()
{
var f = new Fixture();
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True,
- "HQ provides power and 2,000 AE capacity");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
f.SpawnBuilder(0, 19, 20);
f.Step(1); // commit the balance
Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 500 spent
@@ -720,19 +785,19 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint()
public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable()
{
var f = new Fixture();
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True,
- "HQ provides power and 2,000 AE capacity");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20);
uint raw = UnitCommandStateView.ToRawEntityId(barracks);
Assert.That(f.Construction.SellBuilding(raw), Is.True);
Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1250L),
"1000 + 250 (50% floor, provisional)");
- Assert.That(f.Construction.BuildingCount, Is.EqualTo(1), "only the Barracks was sold");
+ Assert.That(f.Construction.BuildingCount, Is.EqualTo(2), "only the Barracks was sold");
Assert.That(f.Construction.IsCellFree(20, 20), Is.True);
f.SpawnBuilder(0, 19, 20);
- f.Step(1); // commit the balance (30 provided, 0 required)
+ f.Step(1); // commit the balance (130 provided, 0 required)
Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True);
uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f));
Assert.That(f.Construction.ValidateSell(0, siteRaw), Is.EqualTo(CommandResultCode.RejectedInvalidTarget),
@@ -743,8 +808,10 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable()
public void CancelConstruction_RefundIsCappedAtStorageCeiling()
{
var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE);
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True,
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True,
"HQ provides power and the 2,000 AE ceiling");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True,
+ "the completed Power plant satisfies the Barracks prerequisite");
f.SpawnBuilder(0, 19, 20);
f.Step(1);
Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 2.000 - 500 = 1.500
@@ -852,6 +919,7 @@ public void Repair_Validation_RejectsNonBuilder_AndUndamagedTarget()
public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
f.SpawnBuilder(0, 19, 20);
f.Step(1); // commit the balance
@@ -870,12 +938,13 @@ public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint()
public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ prerequisite");
EntityId builder = f.SpawnBuilder(0, 19, 20);
Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True);
EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 30, 30);
f.Entities.GetUnitRef(barracks).CurrentHealth = 100;
f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(builder), UnitCommandStateView.ToRawEntityId(barracks));
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 9, 40, 40).IsValid, Is.True); // T2 flag
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 9, 44, 40).IsValid, Is.True); // T2 flag
f.Step(10); // accumulate some site progress
var writer = new SnapshotBlockWriter();
@@ -905,6 +974,7 @@ public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected()
public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
f.SpawnBuilder(0, 19, 20);
EntityId soldier = f.Entities.SpawnUnit(
@@ -942,6 +1012,7 @@ public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation()
public void ProgressSites_ReassignsNonBuilderAssignment_DefenseInDepth()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
EntityId builder = f.SpawnBuilder(0, 19, 20);
f.Step(1);
diff --git a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs
index f793316..aab66bc 100644
--- a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs
+++ b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs
@@ -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.
diff --git a/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs b/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs
index c84fd11..3162115 100644
--- a/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs
+++ b/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs
@@ -31,9 +31,9 @@ public class VictorySystemTests
private const int Capacity = 64;
private const ushort MapSize = 64;
- /// Power plant / Barracks definition ids (SimDefinitions MS-1 table).
+ /// Power plant / DefensePlatform definition ids (SimDefinitions MS-1 table).
private const ushort DefPower = 5;
- private const ushort DefBarracks = 7;
+ private const ushort DefDefensePlatform = 11;
///
/// Minimal canonical host: the systems the victory contract actually
@@ -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.
diff --git a/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs b/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs
index dcb8af9..ce60889 100644
--- a/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs
+++ b/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs
@@ -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()
{
@@ -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);
}
@@ -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);
diff --git a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs
index 82367c4..e27904e 100644
--- a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs
+++ b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs
@@ -107,8 +107,16 @@ public static class AiBehaviorId
/// a copied number, and goes red either way.
///
///
+ ///
+ /// 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.
+ ///
///
- public const int Revision = 6;
+ public const int Revision = 7;
///
/// Hash over every value of the shipped profile. Domain-separated like
diff --git a/Assets/_Project/Scripts/AI.Data/AiProfile.cs b/Assets/_Project/Scripts/AI.Data/AiProfile.cs
index 91c360b..e727550 100644
--- a/Assets/_Project/Scripts/AI.Data/AiProfile.cs
+++ b/Assets/_Project/Scripts/AI.Data/AiProfile.cs
@@ -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.
///
public int PowerReserve { get; }
diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
index b80f4fe..9935045 100644
--- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
+++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
@@ -49,9 +49,10 @@ namespace Nova.AI
///
/// DECISION LOOP (fixed cadence = 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
/// — the identical rules the command executor applies; (2) the Builder is
/// moved next to an unfinished site when it is out of the documented
@@ -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
@@ -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;
}
diff --git a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs
index 64d543d..bcca97c 100644
--- a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs
+++ b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs
@@ -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
- /// and the credit
+ /// and the credit
/// check is the balance the executor charges at placement.
///
///
@@ -64,6 +64,14 @@ public sealed class BuildMenuHud : MonoBehaviour
UnitRole.Radar, UnitRole.DefensePlatform
};
+ /// Stable display order for missing all-of prerequisites.
+ private static readonly UnitRole[] PrerequisiteDisplayOrder =
+ {
+ UnitRole.HQ, UnitRole.Power, UnitRole.Refinery, UnitRole.Storage,
+ UnitRole.Barracks, UnitRole.VehicleFactory, UnitRole.ResearchLab,
+ UnitRole.Radar, UnitRole.DefensePlatform
+ };
+
///
/// The opening-loop hint. German, like the runbook: build a Refinery
/// (Y), produce a Harvester (Q) at it, then harvest (H).
@@ -338,8 +346,7 @@ private void DrawBar()
/// Entry availability, the executor's own rule: prerequisite finished (if any) and enough credits.
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;
}
@@ -418,14 +425,33 @@ private string ButtonLabel(UnitRole role, in SimBuildingDefinition def, float bu
}
/// The hovered entry's blocker, in the executor's own check order — prerequisite first, then affordability.
- 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";
}
diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs
index eee7436..dc341cd 100644
--- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs
+++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs
@@ -36,7 +36,7 @@ namespace Nova.Simulation.Construction
/// mutates). The state-dependent validation order is fixed and
/// deterministic: unknown definition, footprint outside the 128x128
/// grid or occupied cells (RejectedInvalidTarget), then missing
- /// prerequisite role and the power rule (RejectedPrerequisitesNotMet).
+ /// prerequisite roles and the power rule (RejectedPrerequisitesNotMet).
/// The power rule: a building with PowerRequired > 0 may only be
/// placed while the owner's last committed balance
/// (previous tick's phase-2 recompute) covers the additional draw:
@@ -44,9 +44,10 @@ namespace Nova.Simulation.Construction
/// building. The Refinery carries NO prerequisite since D-077 (the
/// classic loop start — quality/content/mvp-v1.json
/// startStatePerPlayer): placed through the command path it is gated
- /// only by funds, footprint and the power rule. The remaining
- /// prerequisite roles (VehicleFactory needs a Refinery, ResearchLab a
- /// Barracks, Radar and DefensePlatform a Power plant) are unchanged.
+ /// only by funds, footprint and the power rule. D-103 represents every
+ /// other prerequisite as a fail-closed all-of mask over completed own
+ /// roles (for example Barracks needs HQ + Power, VehicleFactory needs
+ /// Refinery + Barracks, and Radar needs Power + Barracks).
/// bypasses placement validation
/// entirely — it is the direct write the match start is placed with.
///
@@ -318,21 +319,47 @@ public bool IsActiveSite(EntityId id)
return IndexOfSite(UnitCommandStateView.ToRawEntityId(id)) >= 0;
}
- /// True when the slot owns a COMPLETED building of the given role (prerequisite scans).
- public bool HasFinishedBuilding(byte playerSlot, UnitRole role)
+ ///
+ /// Missing completed own building roles for an all-of prerequisite.
+ /// Unknown bits stay missing (fail closed).
+ ///
+ public UnitRoleMask GetMissingPrerequisiteRoles(byte playerSlot, UnitRoleMask requiredRoles)
{
+ if (requiredRoles == UnitRoleMask.None) return UnitRoleMask.None;
+
+ UnitRoleMask completedRoles = UnitRoleMask.None;
for (int i = 0; i < MaxBuildings; i++)
{
if (!_buildings[i].IsActive) continue;
if (!SimDefinitions.TryGetBuilding(_buildings[i].BuildingDefId, out SimBuildingDefinition def)) continue;
- if (def.Role != role) continue;
+
EntityId id = UnitCommandStateView.ToEntityId(_buildings[i].RawEntityId);
- if (_entityManager.TryGetUnit(id, out UnitState unit) && unit.PlayerId == playerSlot)
- {
- return true;
- }
+ if (!_entityManager.TryGetUnit(id, out UnitState unit) || unit.PlayerId != playerSlot) continue;
+
+ completedRoles |= RoleMask(def.Role);
}
- return false;
+ return requiredRoles & ~completedRoles;
+ }
+
+ /// True when the slot owns every COMPLETED building role in the all-of mask.
+ public bool HasFinishedBuildings(byte playerSlot, UnitRoleMask requiredRoles)
+ {
+ return GetMissingPrerequisiteRoles(playerSlot, requiredRoles) == UnitRoleMask.None;
+ }
+
+ /// True when the slot owns a COMPLETED building of the given role (prerequisite scans).
+ public bool HasFinishedBuilding(byte playerSlot, UnitRole role)
+ {
+ UnitRoleMask roleMask = RoleMask(role);
+ return roleMask != UnitRoleMask.None && HasFinishedBuildings(playerSlot, roleMask);
+ }
+
+ private static UnitRoleMask RoleMask(UnitRole role)
+ {
+ int bit = (int)role;
+ return bit >= 0 && bit < 32
+ ? (UnitRoleMask)(1u << bit)
+ : UnitRoleMask.None;
}
// ------------------------------------------------------------------
@@ -343,7 +370,7 @@ public bool HasFinishedBuilding(byte playerSlot, UnitRole role)
///
/// Full state-dependent placement validation in fixed order: unknown
/// definition, foreign-faction definition, out-of-map footprint and
- /// occupied cells (RejectedInvalidTarget), then prerequisite role,
+ /// occupied cells (RejectedInvalidTarget), then prerequisite roles,
/// power rule and site capacity (RejectedPrerequisitesNotMet). Cost
/// is the executor's separate check (RejectedInsufficientResources)
/// and runs BEFORE this.
@@ -366,7 +393,7 @@ public CommandResultCode ValidatePlacement(byte playerSlot, ushort buildingDefId
{
return CommandResultCode.RejectedInvalidTarget;
}
- if (def.HasPrerequisite && !HasFinishedBuilding(playerSlot, def.PrerequisiteRole))
+ if (!HasFinishedBuildings(playerSlot, def.PrerequisiteRoles))
{
return CommandResultCode.RejectedPrerequisitesNotMet;
}
diff --git a/Assets/_Project/Scripts/Simulation/Definitions/SimDefinitions.cs b/Assets/_Project/Scripts/Simulation/Definitions/SimDefinitions.cs
index 9148722..46d6901 100644
--- a/Assets/_Project/Scripts/Simulation/Definitions/SimDefinitions.cs
+++ b/Assets/_Project/Scripts/Simulation/Definitions/SimDefinitions.cs
@@ -5,6 +5,26 @@
namespace Nova.Simulation.Definitions
{
+ ///
+ /// Set of completed own building roles required before placement. Bit n
+ /// is the stable wire value n of ; UnitRole itself
+ /// remains an unchanged single-value wire enum.
+ ///
+ [Flags]
+ public enum UnitRoleMask : uint
+ {
+ None = 0,
+ HQ = 1u << (int)UnitRole.HQ,
+ Refinery = 1u << (int)UnitRole.Refinery,
+ Power = 1u << (int)UnitRole.Power,
+ Storage = 1u << (int)UnitRole.Storage,
+ Barracks = 1u << (int)UnitRole.Barracks,
+ VehicleFactory = 1u << (int)UnitRole.VehicleFactory,
+ ResearchLab = 1u << (int)UnitRole.ResearchLab,
+ Radar = 1u << (int)UnitRole.Radar,
+ DefensePlatform = 1u << (int)UnitRole.DefensePlatform,
+ }
+
///
/// Canonical numeric definition of one MS-1 building role of ONE faction
/// (quality/content/mvp-v1.json section 3, factions[0]/factions[1]).
@@ -33,11 +53,11 @@ public readonly struct SimBuildingDefinition
/// Power this building draws from its owner's grid once completed.
public int PowerRequired { get; }
- /// True when placement requires a completed own building of .
- public bool HasPrerequisite { get; }
+ /// All completed own building roles required for placement (all-of semantics).
+ public UnitRoleMask PrerequisiteRoles { get; }
- /// Required completed own building role; meaningful only when .
- public UnitRole PrerequisiteRole { get; }
+ /// True when is non-empty.
+ public bool HasPrerequisite => PrerequisiteRoles != UnitRoleMask.None;
/// Hit points of the completed building.
public int MaxHealth { get; }
@@ -84,7 +104,7 @@ public readonly struct SimBuildingDefinition
public SimBuildingDefinition(
ushort definitionId, FactionId faction, UnitRole role, int costAE, int buildTicks,
int powerProvided, int powerRequired,
- bool hasPrerequisite, UnitRole prerequisiteRole, int maxHealth,
+ UnitRoleMask prerequisiteRoles, int maxHealth,
ArmorClass armorClass, DamageType damageType,
int attackDamage, int attackRangeTiles, int attackCooldownTicks)
{
@@ -95,8 +115,7 @@ public SimBuildingDefinition(
BuildTicks = buildTicks;
PowerProvided = powerProvided;
PowerRequired = powerRequired;
- HasPrerequisite = hasPrerequisite;
- PrerequisiteRole = prerequisiteRole;
+ PrerequisiteRoles = prerequisiteRoles;
MaxHealth = maxHealth;
ArmorClass = armorClass;
DamageType = damageType;
@@ -374,29 +393,29 @@ public static int HarvesterCargoCapacityAE(FactionId faction)
private static readonly SimBuildingDefinition[] Buildings =
{
// --- Alliance (factions[0]) ---
- new SimBuildingDefinition(3, FactionId.Alliance, UnitRole.HQ, costAE: 2500, buildTicks: 600, powerProvided: 30, powerRequired: 0, hasPrerequisite: false, prerequisiteRole: UnitRole.Unit, maxHealth: 2000, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(5, FactionId.Alliance, UnitRole.Power, costAE: 450, buildTicks: 150, powerProvided: 100, powerRequired: 0, hasPrerequisite: false, prerequisiteRole: UnitRole.Unit, maxHealth: 400, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(4, FactionId.Alliance, UnitRole.Refinery, costAE: 700, buildTicks: 200, powerProvided: 0, powerRequired: 20, hasPrerequisite: false, prerequisiteRole: UnitRole.Unit, maxHealth: 800, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0), // D-077: no Power-plant prerequisite
- new SimBuildingDefinition(6, FactionId.Alliance, UnitRole.Storage, costAE: 300, buildTicks: 100, powerProvided: 0, powerRequired: 5, hasPrerequisite: false, prerequisiteRole: UnitRole.Unit, maxHealth: 400, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(7, FactionId.Alliance, UnitRole.Barracks, costAE: 500, buildTicks: 180, powerProvided: 0, powerRequired: 15, hasPrerequisite: false, prerequisiteRole: UnitRole.Unit, maxHealth: 600, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(8, FactionId.Alliance, UnitRole.VehicleFactory, costAE: 900, buildTicks: 250, powerProvided: 0, powerRequired: 25, hasPrerequisite: true, prerequisiteRole: UnitRole.Refinery, maxHealth: 900, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(9, FactionId.Alliance, UnitRole.ResearchLab, costAE: 1000, buildTicks: 300, powerProvided: 0, powerRequired: 30, hasPrerequisite: true, prerequisiteRole: UnitRole.Barracks, maxHealth: 700, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(10, FactionId.Alliance, UnitRole.Radar, costAE: 400, buildTicks: 150, powerProvided: 0, powerRequired: 20, hasPrerequisite: true, prerequisiteRole: UnitRole.Power, maxHealth: 500, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(11, FactionId.Alliance, UnitRole.DefensePlatform, costAE: 400, buildTicks: 120, powerProvided: 0, powerRequired: 10, hasPrerequisite: true, prerequisiteRole: UnitRole.Power, maxHealth: 600, armorClass: ArmorClass.Building, damageType: DamageType.Kinetic, attackDamage: 20, attackRangeTiles: 10, attackCooldownTicks: 10),
+ new SimBuildingDefinition(3, FactionId.Alliance, UnitRole.HQ, costAE: 2500, buildTicks: 600, powerProvided: 30, powerRequired: 0, prerequisiteRoles: UnitRoleMask.None, maxHealth: 2000, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(5, FactionId.Alliance, UnitRole.Power, costAE: 450, buildTicks: 150, powerProvided: 100, powerRequired: 0, prerequisiteRoles: UnitRoleMask.HQ, maxHealth: 400, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(4, FactionId.Alliance, UnitRole.Refinery, costAE: 700, buildTicks: 200, powerProvided: 0, powerRequired: 20, prerequisiteRoles: UnitRoleMask.None, maxHealth: 800, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0), // D-077: no Power-plant prerequisite
+ new SimBuildingDefinition(6, FactionId.Alliance, UnitRole.Storage, costAE: 300, buildTicks: 100, powerProvided: 0, powerRequired: 5, prerequisiteRoles: UnitRoleMask.Refinery, maxHealth: 400, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(7, FactionId.Alliance, UnitRole.Barracks, costAE: 500, buildTicks: 180, powerProvided: 0, powerRequired: 15, prerequisiteRoles: UnitRoleMask.HQ | UnitRoleMask.Power, maxHealth: 600, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(8, FactionId.Alliance, UnitRole.VehicleFactory, costAE: 900, buildTicks: 250, powerProvided: 0, powerRequired: 25, prerequisiteRoles: UnitRoleMask.Refinery | UnitRoleMask.Barracks, maxHealth: 900, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(9, FactionId.Alliance, UnitRole.ResearchLab, costAE: 1000, buildTicks: 300, powerProvided: 0, powerRequired: 30, prerequisiteRoles: UnitRoleMask.VehicleFactory, maxHealth: 700, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(10, FactionId.Alliance, UnitRole.Radar, costAE: 400, buildTicks: 150, powerProvided: 0, powerRequired: 20, prerequisiteRoles: UnitRoleMask.Power | UnitRoleMask.Barracks, maxHealth: 500, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(11, FactionId.Alliance, UnitRole.DefensePlatform, costAE: 400, buildTicks: 120, powerProvided: 0, powerRequired: 10, prerequisiteRoles: UnitRoleMask.Power, maxHealth: 600, armorClass: ArmorClass.Building, damageType: DamageType.Kinetic, attackDamage: 20, attackRangeTiles: 10, attackCooldownTicks: 10),
// --- Legion (factions[1]): Buildings.md section 2 concrete values;
// HP derived: (alliance * 85) / 100 (derivation rule). The
// DefensePlatform weapon stays identical — faction-neutral module
// content (Buildings.md section 3). ---
- new SimBuildingDefinition(20, FactionId.Legion, UnitRole.HQ, costAE: 2000, buildTicks: 500, powerProvided: 30, powerRequired: 0, hasPrerequisite: false, prerequisiteRole: UnitRole.Unit, maxHealth: 1700, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(22, FactionId.Legion, UnitRole.Power, costAE: 350, buildTicks: 120, powerProvided: 80, powerRequired: 0, hasPrerequisite: false, prerequisiteRole: UnitRole.Unit, maxHealth: 340, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(21, FactionId.Legion, UnitRole.Refinery, costAE: 550, buildTicks: 160, powerProvided: 0, powerRequired: 15, hasPrerequisite: false, prerequisiteRole: UnitRole.Unit, maxHealth: 680, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0), // D-077: no Power-plant prerequisite
- new SimBuildingDefinition(23, FactionId.Legion, UnitRole.Storage, costAE: 250, buildTicks: 80, powerProvided: 0, powerRequired: 5, hasPrerequisite: false, prerequisiteRole: UnitRole.Unit, maxHealth: 340, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(24, FactionId.Legion, UnitRole.Barracks, costAE: 400, buildTicks: 140, powerProvided: 0, powerRequired: 10, hasPrerequisite: false, prerequisiteRole: UnitRole.Unit, maxHealth: 510, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(25, FactionId.Legion, UnitRole.VehicleFactory, costAE: 700, buildTicks: 200, powerProvided: 0, powerRequired: 20, hasPrerequisite: true, prerequisiteRole: UnitRole.Refinery, maxHealth: 765, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(26, FactionId.Legion, UnitRole.ResearchLab, costAE: 800, buildTicks: 240, powerProvided: 0, powerRequired: 25, hasPrerequisite: true, prerequisiteRole: UnitRole.Barracks, maxHealth: 595, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(27, FactionId.Legion, UnitRole.Radar, costAE: 300, buildTicks: 120, powerProvided: 0, powerRequired: 15, hasPrerequisite: true, prerequisiteRole: UnitRole.Power, maxHealth: 425, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
- new SimBuildingDefinition(28, FactionId.Legion, UnitRole.DefensePlatform, costAE: 300, buildTicks: 100, powerProvided: 0, powerRequired: 8, hasPrerequisite: true, prerequisiteRole: UnitRole.Power, maxHealth: 510, armorClass: ArmorClass.Building, damageType: DamageType.Kinetic, attackDamage: 20, attackRangeTiles: 10, attackCooldownTicks: 10),
+ new SimBuildingDefinition(20, FactionId.Legion, UnitRole.HQ, costAE: 2000, buildTicks: 500, powerProvided: 30, powerRequired: 0, prerequisiteRoles: UnitRoleMask.None, maxHealth: 1700, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(22, FactionId.Legion, UnitRole.Power, costAE: 350, buildTicks: 120, powerProvided: 80, powerRequired: 0, prerequisiteRoles: UnitRoleMask.HQ, maxHealth: 340, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(21, FactionId.Legion, UnitRole.Refinery, costAE: 550, buildTicks: 160, powerProvided: 0, powerRequired: 15, prerequisiteRoles: UnitRoleMask.None, maxHealth: 680, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0), // D-077: no Power-plant prerequisite
+ new SimBuildingDefinition(23, FactionId.Legion, UnitRole.Storage, costAE: 250, buildTicks: 80, powerProvided: 0, powerRequired: 5, prerequisiteRoles: UnitRoleMask.Refinery, maxHealth: 340, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(24, FactionId.Legion, UnitRole.Barracks, costAE: 400, buildTicks: 140, powerProvided: 0, powerRequired: 10, prerequisiteRoles: UnitRoleMask.HQ | UnitRoleMask.Power, maxHealth: 510, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(25, FactionId.Legion, UnitRole.VehicleFactory, costAE: 700, buildTicks: 200, powerProvided: 0, powerRequired: 20, prerequisiteRoles: UnitRoleMask.Refinery | UnitRoleMask.Barracks, maxHealth: 765, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(26, FactionId.Legion, UnitRole.ResearchLab, costAE: 800, buildTicks: 240, powerProvided: 0, powerRequired: 25, prerequisiteRoles: UnitRoleMask.VehicleFactory, maxHealth: 595, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(27, FactionId.Legion, UnitRole.Radar, costAE: 300, buildTicks: 120, powerProvided: 0, powerRequired: 15, prerequisiteRoles: UnitRoleMask.Power | UnitRoleMask.Barracks, maxHealth: 425, armorClass: ArmorClass.Building, damageType: Unarmed, attackDamage: 0, attackRangeTiles: 0, attackCooldownTicks: 0),
+ new SimBuildingDefinition(28, FactionId.Legion, UnitRole.DefensePlatform, costAE: 300, buildTicks: 100, powerProvided: 0, powerRequired: 8, prerequisiteRoles: UnitRoleMask.Power, maxHealth: 510, armorClass: ArmorClass.Building, damageType: DamageType.Kinetic, attackDamage: 20, attackRangeTiles: 10, attackCooldownTicks: 10),
};
private static readonly SimUnitDefinition[] Units =
@@ -537,10 +556,10 @@ public static bool IsBuildingRole(UnitRole role)
/// NOVA_DEFINITIONS_V1 domain (SimulationCore.md section 5) over ids
/// 1.. in ascending order; each
/// definition contributes a field tag (its id) followed by a uniform
- /// 21-field layout in canonical order: kind u8 (0 = building, 1 =
+ /// 19-field layout in canonical order: kind u8 (0 = building, 1 =
/// unit), faction u8, role u8, costAE i32, buildTicks i32,
/// powerProvided i32, powerRequired i32, hasPrerequisite u8,
- /// prerequisiteRole u8, tier u8, producerRole u8, maxHealth i32,
+ /// prerequisiteRoles u32, tier u8, producerRole u8, maxHealth i32,
/// moveSpeed raw i32, armorClass u8, damageType u8, attackDamage i32,
/// attackRangeTiles i32, attackCooldownTicks i32, cargoCapacityAE i32.
/// Fields a kind does
@@ -598,7 +617,7 @@ private static void WriteBuildingRow(SimHashWriter hash, in SimBuildingDefinitio
hash.WriteInt32(def.PowerProvided);
hash.WriteInt32(def.PowerRequired);
hash.WriteUInt8(def.HasPrerequisite ? (byte)1 : (byte)0);
- hash.WriteUInt8((byte)def.PrerequisiteRole);
+ hash.WriteUInt32((uint)def.PrerequisiteRoles);
hash.WriteUInt8(0); // tier: buildings have none
hash.WriteUInt8(0); // producerRole: buildings have none
hash.WriteInt32(def.MaxHealth);
@@ -622,7 +641,7 @@ private static void WriteUnitRow(SimHashWriter hash, in SimUnitDefinition def)
hash.WriteInt32(0); // powerProvided: units have none
hash.WriteInt32(0); // powerRequired: units have none
hash.WriteUInt8(0); // hasPrerequisite: units have none
- hash.WriteUInt8(0); // prerequisiteRole: units have none
+ hash.WriteUInt32(0); // prerequisiteRoles: units have none
hash.WriteUInt8(def.Tier);
hash.WriteUInt8((byte)def.ProducerRole);
hash.WriteInt32(def.MaxHealth);
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d377a05..6096b44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,7 +13,7 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
## [Unreleased]
-> **Dokumentationsstand 0.17.0 (unveröffentlicht):** Dieses Rebaseline ist ein
+> **Dokumentationsstand 0.20.0 (unveröffentlicht):** Dieses Rebaseline ist ein
> Wiki-/Vertrags-Minor und kein Game-Release. Es wird kein Tag oder Release
> erzeugt; MS-0 und MS-1 bleiben offen.
@@ -101,9 +101,11 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
2.000/2.000/25/10 erstmals in `RulesHash64`: alte Dateien bleiben lesbar,
eine Wiedergabe oder ein Lockstep-Start mit dem alten leeren Rules-Stub wird
vor Tick 1 abgelehnt. Der kanonische KI-Endzustand
- bleibt bei Tick 2.546 entschieden und bewegt sich durch diese Wirtschaftsregel
+ blieb auf dem Stand von Paket 16.4 bei Tick 2.546 entschieden und bewegte
+ sich durch diese Wirtschaftsregel
von `0x9F93097AD526B6F7` auf `0xE784E6184AD16081`; die KI-Kennung bleibt
- unverändert `r6.E34435F9`
+ dort unverändert `r6.E34435F9` (der spätere D-103-Handoff ist separat unter
+ „Geändert“ dokumentiert)
- **#44: Baustellen schiessen nicht mehr** — die Baustelle trägt jetzt ihre
Definitionsrolle statt `UnitRole.Unit`; der bewaffnete Fallback-Slot der
Waffentabelle greift nicht mehr, und `CombatSystem` schliesst zusätzlich jede aktive
@@ -214,6 +216,23 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
gespulte Partie ohne dieses Etikett nichts wert ist.
### Geändert
+- **16.8/D-103: Bauvoraussetzungen sind jetzt echte All-of-Ketten.** Eine
+ separate `UnitRoleMask` ersetzt das singuläre `PrerequisiteRole`, beide
+ Fraktionen verwenden dieselbe neun Rollen umfassende Kette, und Executor wie
+ Baubar leiten alle fehlenden fertigen eigenen Gebäude aus derselben
+ fail-closed Maske ab. Die Maske ist vollständig vom `DefinitionsHash64`
+ gedeckt; Relay und Clients müssen daher aus demselben Commit stammen. Kein
+ Zustands- oder Befehlsformat und keine Golden-Baseline wurde geändert. Die
+ nach D-105 begrenzte KI-Integrationsreparatur lässt die Legion das von D-103
+ verlangte Kraftwerk zwischen Raffinerie und Kaserne fertigstellen; die
+ Verhaltenskennung steigt deshalb von `r6.E34435F9` auf `r7.E34435F9`. Der
+ kanonische Ausgang bewegt sich auf dem integrierten Head von Tick 2.546 /
+ `0xE784E6184AD16081` auf Tick 2.705 / `0x28F2CC571BCE6B76`. Der in den
+ KI-Kommentaren vorgeschriebene externe Pfad
+ `tools/Nova.AiLab/reports/behavior-log.md` ist in diesem Repository nicht
+ vorhanden; die Messung wird deshalb ehrlich hier und im PR dokumentiert,
+ statt einen nicht existierenden Journalnachweis zu behaupten. Die dauerhafte
+ Schreibhoheit des Einheitenstrangs bleibt unverändert.
- **Der Ausgangspin der kanonischen KI-Partie ist vom Identitätspin getrennt
(D-101).** `SkirmishAiTests` pinnte Kennung, Entscheidungstick und
Endzustands-Hash in einer Zusicherung. Die beiden Zahlen bewegen sich aber bei
diff --git a/docs/gamedesign/Buildings.md b/docs/gamedesign/Buildings.md
index d355a2f..5d7a2b1 100644
--- a/docs/gamedesign/Buildings.md
+++ b/docs/gamedesign/Buildings.md
@@ -1,6 +1,6 @@
# Gebäude – alle Fraktionen
-**Version:** 0.6.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 16
+**Version:** 0.7.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 16
## Zweck
@@ -8,7 +8,7 @@ Spielbare Spezifikation aller 12 Gebäudetypen (D-008) für die drei Fraktionen
## Abhängigkeiten
-- [../production/DecisionLog.md](../production/DecisionLog.md) – D-008 (12 Typen), D-010 (Aetherium-Wirtschaft), D-011 (Evolvierte-Wachstum), D-012 (Zerstörbarkeit), D-022 (Capture-System), D-023 (Superwaffen-Limit), D-024 (Lager & Raffinerie), D-030 (Low-Power/Forschung), D-077 (MS-1-Startzustand), D-096/D-106 (abgeleitete AE-Grenze und Überhang)
+- [../production/DecisionLog.md](../production/DecisionLog.md) – D-008 (12 Typen), D-010 (Aetherium-Wirtschaft), D-011 (Evolvierte-Wachstum), D-012 (Zerstörbarkeit), D-022 (Capture-System), D-023 (Superwaffen-Limit), D-024 (Lager & Raffinerie), D-030 (Low-Power/Forschung), D-077 (MS-1-Startzustand), D-096 (abgeleitete AE-Grenze), D-103 (MS-1-Bauvoraussetzungen), D-106 (zustandsloser Überhang)
- [./Factions.md](./Factions.md) – Fraktionsidentitäten
- [./Economy.md](./Economy.md) – AE-Währung, Energie, Low-Power-Regel, Harvester-Werte, Lager-Kapazität (D-024)
- [./ResearchTree.md](./ResearchTree.md) – Tech-Tiers 1–3
@@ -25,13 +25,32 @@ Raffinerie, Lager, Kaserne, Fahrzeugfabrik, Forschungslabor, Radar und
Verteidigungsplattform – jeweils mit den dort festgelegten Allianz- und
Legionsnamen. Mauern, Flugfeld und Superwaffe bleiben Post-MVP.
-Jede Seite startet mit fertiggestelltem HQ und fertiggestellter Raffinerie. Nur
-diese Start-Raffinerie darf ihre normale Kraftwerk-Voraussetzung umgehen und
-erzeugt keinen zusätzlichen Harvester. Ein fertiggestelltes Forschungslabor
-schaltet T2 unmittelbar frei; Forschung, Forschungsqueue und T3 sind
-deaktiviert. Die Verteidigungsplattform unterstützt `MG` auf T1 und `Rocket`
-auf T2; `Flak` ist nicht Teil von MS-1. Bei Widerspruch übersteuert dieser
-Abschnitt den nachfolgenden Vollspielentwurf.
+Jede Seite startet gemäß D-077 mit einem fertiggestellten HQ und einem Builder,
+aber ohne Raffinerie. Die erste fertiggebaute Raffinerie erzeugt den ersten
+Harvester; die Raffinerie selbst hat in MS-1 keine Bauvoraussetzung.
+
+Für die Platzierung gelten gemäß D-103 folgende **All-of-Voraussetzungen** bei
+Allianz und Legion identisch. Jeder genannte Gebäudetyp muss als eigenes,
+fertiggestelltes Gebäude vorhanden sein; Baustellen genügen nicht.
+
+| Gebäude | MS-1-Platzierungsvoraussetzungen |
+|---|---|
+| HQ | keine |
+| Kraftwerk | HQ |
+| Raffinerie | keine |
+| Lager | Raffinerie |
+| Kaserne | HQ **und** Kraftwerk |
+| Fahrzeugfabrik | Raffinerie **und** Kaserne |
+| Forschungslabor | Fahrzeugfabrik |
+| Radar | Kraftwerk **und** Kaserne |
+| Verteidigungsplattform (Basis) | Kraftwerk |
+
+Ein fertiggestelltes Forschungslabor schaltet T2 unmittelbar frei; Forschung,
+Forschungsqueue und T3 sind deaktiviert. Die Voraussetzung der
+Verteidigungsplattform meint nur das Basisgebäude. Die Modulfreischaltungen aus
+§3 (Kaserne/Radar/Forschungslabor) bleiben davon getrennt. Die Plattform
+unterstützt `MG` auf T1 und `Rocket` auf T2; `Flak` ist nicht Teil von MS-1.
+Bei Widerspruch übersteuert dieser Abschnitt den nachfolgenden Vollspielentwurf.
## 1. Grundprinzipien
@@ -268,3 +287,4 @@ Entschieden und entfernt im Korrekturlauf Sprint 4: HQ-Grundenergie (+30 führen
| 0.4.1 | 2026-07-21 | Offener Punkt "Flak-DPS-Korridor" geschlossen: veralteter Querverweis auf Aircraft.md (90 DPS) entfernt, auf Weapons.md als einzige Werte-Quelle (25–40 Schaden/1,5 s, ×2,0 vs. Luft ≈ 33–53 DPS, D-047) umformuliert | Lead Gameplay Designer |
| 0.5.0 | 2026-07-24 | Neun Gebäuderollen, Start-Ausnahme, T2-Freischaltung und MG-/Raketenmodule für MS-1 gemäß D-056 abgegrenzt | Lead Gameplay Designer |
| 0.6.0 | 2026-08-10 | MS-1-Startwert (D-077) und Lagervertrag (D-106) nachgezogen: eine 2.000-AE-HQ-Basis je Konto, +2.000 je fertigem Lager und periodischer 25-%-Abbau des aktuellen Überhangs statt separatem Zerstörungsabzug | Codex / Dennis Westermann |
+| 0.7.0 | 2026-08-10 | Die neun fraktionsgleichen All-of-Platzierungsvoraussetzungen samt Trennung von Plattformbasis und Modulen gemäß D-103 festgeschrieben | Agent (unter Delegation) / Dennis Westermann |
diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md
index 0ba7eb5..5bb5322 100644
--- a/docs/production/DecisionLog.md
+++ b/docs/production/DecisionLog.md
@@ -1,6 +1,6 @@
# Decision Log
-**Version:** 1.36.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 16
+**Version:** 1.37.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 16
## Zweck
@@ -3051,6 +3051,70 @@ Zeitkurve und bleibt bis dahin offen.
---
+### D-103 | verbindlich | Sprint 16 (Bauvoraussetzungen werden eine All-of-Bitmaske)
+
+**Status:** Am 2026-08-09 vom Agenten unter ausdrücklicher Inhaberdelegation
+entschieden; überstimmbar. Umsetzung und Integrationsnachweis in Paket 16.8.
+
+**Kontext:** `SimBuildingDefinition.PrerequisiteRole` konnte genau eine fertige
+Gebäuderolle ausdrücken. Der verbindliche Gebäudeentwurf nennt dagegen für
+Kaserne, Fahrzeugfabrik und Radar mehrere gleichzeitige Voraussetzungen. Eine
+zweite Spezialprüfung pro Rolle würde Definitionstabelle, Executor und UI
+auseinanderlaufen lassen. Das Feld ist außerdem Teil von `DefinitionsHash64`;
+seine Darstellung ist deshalb Match- und Relay-Kompatibilität, nicht nur eine
+lokale C#-Signatur.
+
+**Alternativen:**
+
+1. Das einzelne Feld behalten und Mehrfachregeln im `ConstructionSystem`
+ hartcodieren — verworfen, weil Definition, Executor und Baubar dann drei
+ Wahrheiten pflegen.
+2. Eine variable Liste je Definition — verworfen, weil sie Allokation,
+ Ordnungsregeln und eine längere Hash-Kodierung einführt, obwohl höchstens
+ neun stabile Rollen abzubilden sind.
+3. **Gewählt: eine `uint`-Bitmaske über die unveränderten `UnitRole`-Wirewerte.**
+ Bit `n` steht für Rollenwert `n`; die Prüfung verlangt alle gesetzten Bits.
+
+**Entscheidung:**
+
+1. `UnitRole` bleibt unverändert. `UnitRoleMask` ist ein separates
+ `[Flags]`-Enum; `SimBuildingDefinition.PrerequisiteRoles` ersetzt das
+ singuläre Feld. `HasPrerequisite` bleibt abgeleitet (`mask != 0`).
+2. Allianz und Legion verwenden identisch: HQ keine; Kraftwerk HQ; Raffinerie
+ keine; Lager Raffinerie; Kaserne HQ + Kraftwerk; Fahrzeugfabrik Raffinerie +
+ Kaserne; Forschungslabor Fahrzeugfabrik; Radar Kraftwerk + Kaserne;
+ Verteidigungsplattform Kraftwerk.
+3. Die Raffinerie bleibt gemäß D-077 voraussetzungslos. Bei der
+ Verteidigungsplattform betrifft die Kraftwerk-Voraussetzung nur die Basis;
+ Modulfreischaltungen bleiben ein getrennter Vertrag.
+4. Nur eigene, fertiggestellte Gebäude erfüllen Bits. Baustellen, fremde
+ Gebäude und unbekannte Bits erfüllen nichts; unbekannte Bits scheitern damit
+ geschlossen.
+5. `DefinitionsHash64` schreibt weiterhin den abgeleiteten
+ `hasPrerequisite u8` und danach die vollständige Maske als `u32`. Der Hash
+ bewegt sich absichtlich; Relay und Clients müssen aus demselben Commit
+ stammen. Zustands-, Befehls- und Relay-Protokollversionen ändern sich nicht.
+
+**Begründung:** Die Maske ist die kleinste tabellarische Darstellung, die
+All-of vollständig ausdrückt, ohne `UnitRole` oder persistenten Zustand zu
+verändern. Eine gemeinsame Missing-Mask-Abfrage lässt Executor und UI exakt
+dieselbe Semantik verwenden und kann alle fehlenden Rollen stabil benennen.
+
+**Konsequenzen:** Die Skirmish-KI muss vor der Kaserne ein Kraftwerk planen.
+Der Eingriff liegt im fremden `Scripts/AI*`-Bereich und wird nach D-105 als
+kleinste gebundene Integrationsreparatur im selben Paket geführt: Kennung r7,
+gespiegelter Baufolgetest und frisch gemessener kanonischer Ausgang sind
+Pflicht; die dauerhafte Schreibhoheit ändert sich nicht. Der in den
+KI-Kommentaren referenzierte externe Pfad
+`tools/Nova.AiLab/reports/behavior-log.md` existiert in diesem Repository
+nicht. Messwerte und fehlender Journalpfad werden deshalb offen in Changelog
+und PR dokumentiert, statt einen Nachweis zu erfinden. Paket 16.8 wird ohne
+grünen Integrationslauf nicht gemergt. Der Definitions-Hash macht alte
+Relay-/Client-Builds bewusst inkompatibel. Keine Golden-Baseline und kein
+persistentes Zustandsformat wird in diesem Paket geändert.
+
+---
+
### D-105 | verbindlich | Sprint 16 (alleinige Projektleitung und Merge-Autorität)
**Status:** unmittelbar wirksame Inhaberentscheidung vom 2026-08-10 (Dennis
@@ -3310,6 +3374,7 @@ Konvergenz, `long.MaxValue` und die Ablehnung des alten Rules-Stubs ab.
| Version | Datum | Änderung | Autor |
|---|---|---|---|
+| 1.37.0 | 2026-08-10 | D-103 aufgenommen: Bauvoraussetzungen werden eine fraktionsgleiche All-of-Maske über unveränderte `UnitRole`-Wirewerte; Hash-, Fail-Closed-, Plattformmodul- und KI-Handoff-Folgen festgeschrieben | Agent (unter Delegation) / Dennis Westermann |
| 1.36.0 | 2026-08-10 | D-102 aufgenommen: fünf symmetrische endliche Aetheriumfelder werden geliefert, die mangels belastbarer Zielkurve unveränderte Ernterate von 2 AE/Tick wird ausdrücklich getrennt kalibriert | Project Owner / Agent |
| 1.35.0 | 2026-08-10 | D-106 aufgenommen: einmalige 2.000-AE-HQ-Basis, +2.000 je fertigem Lager, sofort gedeckelte Einzahlungen, zustandsloser 25-%-Abbau des aktuellen Überhangs je Sekunde und kanonischer Rules-Hash; D-024/D-096 in Verlustausformung, HQ-Stapelung und Replay-Kompatibilität teilweise ersetzt; Fehlverweise der Lobbyfamilie D-095–D-097 auf D-092–D-094 berichtigt | Agent (unter Delegation) / Dennis Westermann |
| 1.34.0 | 2026-08-10 | D-105 aufgenommen: Dennis Westermann ist alleiniger Projektinhaber, Tier-Entscheider und Mergeberechtigter; Tier 2 bleibt mit CLA und aktueller Inhaberfreigabe aktiv, Inhaber-PRs dürfen nach grüner Pflicht-CI und unabhängigem Review selbst gemergt werden, und manuelle Spielabnahme darf ehrlich zurückgestellt, aber nicht als gelaufen behauptet werden | Project Owner / Orchestrator |
diff --git a/docs/production/hashkrieg/16_Sprint_Wirtschaft.md b/docs/production/hashkrieg/16_Sprint_Wirtschaft.md
index ed11f2c..aa67fb8 100644
--- a/docs/production/hashkrieg/16_Sprint_Wirtschaft.md
+++ b/docs/production/hashkrieg/16_Sprint_Wirtschaft.md
@@ -1,6 +1,6 @@
# Sprint 16: Die Wirtschaft trägt sich selbst — kein Gebäude kostet Geld, ohne etwas zu tun
-**Version:** 1.4.0 | **Status:** in Umsetzung | **Verantwortungsbereich:** Netzstrang (Maintainer) | **Sprint:** 16 | **Vorgänger:** [12_Sprint_Zu_Zweit.md](12_Sprint_Zu_Zweit.md) Strang C | **Parallel zu:** [13B](13B_Sprint_Einheitenverhalten.md) | **Regelwerk:** [13-15_Parallelbetrieb.md](13-15_Parallelbetrieb.md) | **UX-Gate:** human | **Leitsatz:** ein Gebäude, das Strom zieht und nichts tut, ist kein Platzhalter, sondern ein Schaden
+**Version:** 1.5.0 | **Status:** in Umsetzung | **Verantwortungsbereich:** Netzstrang (Maintainer) | **Sprint:** 16 | **Vorgänger:** [12_Sprint_Zu_Zweit.md](12_Sprint_Zu_Zweit.md) Strang C | **Parallel zu:** [13B](13B_Sprint_Einheitenverhalten.md) | **Regelwerk:** [13-15_Parallelbetrieb.md](13-15_Parallelbetrieb.md) | **UX-Gate:** human | **Leitsatz:** ein Gebäude, das Strom zieht und nichts tut, ist kein Platzhalter, sondern ein Schaden
## Zweck
@@ -30,7 +30,7 @@ Sprint 16 vorzuziehen. Die beiden dort offenen Wirtschaftsfragen (#53 Lager,
- [16-19_Betatest_Einordnung.md](16-19_Betatest_Einordnung.md) — Herkunft der Issues #43–#58
- [../MVPContentManifest.md](../MVPContentManifest.md) — Feldreserve-Sollwerte
-## Ausgangslage — am Code geprüft, nicht aus dem Masterplan übernommen
+## Ausgangslage am 2026-08-09 — am damaligen Code geprüft, nicht aus dem Masterplan übernommen
| Befund | Beleg |
|---|---|
@@ -60,7 +60,7 @@ sondern ein Platzierungsfehler.** Das ändert den Aufwand, nicht die Dringlichke
| `Scripts/Simulation/Production/` | 16.2 |
| `Scripts/Simulation/Vision/FogOfWarSystem.cs` | 16.5 — Vertragsfläche, `GetTeamView` bleibt unverändert |
| `Scripts/Simulation/State/UnitCommandStateView.cs` | 16.10 — **nur Befehlsanwendung**, kein Feld, keine Reihenfolge, kein `StateVersion` |
-| `Scripts/Simulation/Definitions/SimDefinitions.cs` | 16.8 — `PrerequisiteRole`. **Geteilt mit 13B, Absprache vor dem PR** |
+| `Scripts/Simulation/Definitions/SimDefinitions.cs` | 16.8 — `PrerequisiteRoles`. **Geteilt mit 13B, Absprache vor dem PR** |
| `Scripts/Gameplay/Match/MatchBootstrap.cs` | 16.7 — Startaufstellung |
| `Scripts/Presentation/Maps/GlutrinneBlockoutView.cs` | 16.7 — Feldmarker und Steinstreu-Ausschluss |
| `Scripts/Presentation/UI/` (`BuildMenuHud`, `MinimapHud`, `MatchFrameHud`) | 16.5, 16.10 |
@@ -261,14 +261,42 @@ Knappheitskorrektur ausdrücklich von der noch unbelegten Ernteraten-Kalibrierun
### 16.8 · Die Bauvoraussetzungs-Kette (C5) — **fasst `SimDefinitions` an**
-`SimBuildingDefinition.PrerequisiteRole` ist ein **einzelnes** Feld; das Design
-nennt für sechs von neun Rollen Mehrfachvoraussetzungen. Eine Bitmaske über
-`UnitRole` reicht.
+`SimBuildingDefinition.PrerequisiteRole` war ein **einzelnes** Feld. D-103
+ersetzt es durch `UnitRoleMask PrerequisiteRoles`; `UnitRole` selbst bleibt als
+Wire-Enum unverändert. Die Prüfung ist All-of und scheitert bei unbekannten Bits
+geschlossen. Für Allianz und Legion gilt dieselbe Tabelle:
-> **`PrerequisiteRole` geht in `DefinitionsHash64` ein**
-> (`hash.WriteUInt8((byte)def.PrerequisiteRole)`). Eine Formatänderung bewegt den
-> Definitions-Hash, und der Relay vergleicht ihn serverseitig. Deshalb liegt
-> dieses Paket **vor** dem VPS-Rollout, nicht danach.
+| Rolle | Fertige eigene Voraussetzungen |
+|---|---|
+| HQ | keine |
+| Kraftwerk | HQ |
+| Raffinerie | keine |
+| Lager | Raffinerie |
+| Kaserne | HQ + Kraftwerk |
+| Fahrzeugfabrik | Raffinerie + Kaserne |
+| Forschungslabor | Fahrzeugfabrik |
+| Radar | Kraftwerk + Kaserne |
+| Verteidigungsplattform | Kraftwerk |
+
+Die Baubar zählt alle aktuell fehlenden Rollen in stabiler Reihenfolge auf.
+Die singuläre Abfrage bleibt für Radar-/Onboarding-Verbraucher erhalten und
+delegiert auf dieselbe Maskenprüfung.
+
+> **`PrerequisiteRoles` geht in `DefinitionsHash64` ein**
+> (`hasPrerequisite u8`, danach `prerequisiteRoles u32`). Die Formatänderung
+> bewegt den Definitions-Hash, und der Relay vergleicht ihn serverseitig.
+> Deshalb müssen Relay und beide Clients aus demselben Commit ausgerollt werden;
+> das Paket liegt **vor** dem VPS-Rollout, nicht danach.
+
+Die ausgelieferte Skirmish-KI plante bislang Raffinerie → Kaserne. Das neue
+Kraftwerk-Tor wird nach D-105 durch eine begrenzte Integrationsreparatur im
+fremden `Scripts/AI*`-Schreibbereich bedient: r7 plant Raffinerie → Kraftwerk →
+Kaserne, beide Testspiegel sichern die Reihenfolge. Der kanonische Ausgang wurde
+auf dem integrierten Head mit Tick 2.705 und `0x28F2CC571BCE6B76` frisch
+gemessen; die lokale Gesamtsuite ist mit 685/685 grün. Der Merge wartet danach
+noch auf die grüne PR-CI. Der referenzierte externe AiLab-Journalpfad ist in
+diesem Repository nicht vorhanden, daher stehen Messung und Restrisiko ehrlich
+in Changelog und PR statt in einem erfundenen Artefakt.
### 16.9 · Platzierungsregeln und Reparaturkosten (C6)
@@ -327,17 +355,17 @@ Drei kleine Eingriffe, die zusammengehören, weil sie dasselbe Regelwerk berühr
| Risiko | Umgang |
|---|---|
-| **Der Relay lehnt nach 16.8 alle Clients ab** | `PrerequisiteRole` bewegt `DefinitionsHash64`, der Relay vergleicht ihn serverseitig. 16.8 liegt **vor** dem VPS-Rollout; danach kostet dieselbe Änderung einen Serverzugang. 16.7 ist davon **nicht** betroffen |
+| **Der Relay lehnt nach 16.8 alle alten Clients ab** | `PrerequisiteRoles` bewegt `DefinitionsHash64`, der Relay vergleicht ihn serverseitig. 16.8 liegt **vor** dem VPS-Rollout; danach kostet dieselbe Änderung einen Serverzugang. 16.7 ist davon **nicht** betroffen |
| **Vier von fünf Spiegeln der Startaufstellung gepflegt** | roter Test, der wie ein Determinismusfehler aussieht — oder drei Aetherium-Felder ohne sichtbaren Marker. Die fünf Stellen stehen in 16.7 |
| **Baseline und Verhalten im selben PR** | wird nicht gemergt. `Determinism10000Scenario.cs` liegt ausserhalb der Guard-Präfixe und darf im selben PR nachgezogen werden — `Determinism10000Tests.cs` nicht |
| **Ein 13B-Merge im selben Fenster** | ein Fenster hat einen Strang (Regelwerk, Merge-Fenster) |
| **Die Minimap-Sperre wird als Rückschritt gelesen** | der Bauknopf erklärt, was das Radar freischaltet; der Befund geht in die nächste Testrunde |
-| **`dotnet test` läuft auf der Arbeitsmaschine nicht** | `global.json` pinnt `8.0.318` mit `rollForward: disable`, installiert ist 10.0.302. Der Nachweis läuft über die CI im PR |
+| **Lokales SDK wird übersehen** | Das Repository enthält `.dotnet/sdk/8.0.318`; der kanonische lokale Lauf ist `./.dotnet/dotnet test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release --no-restore`, anschließend bestätigt die PR-CI denselben Umfang |
| **Reparaturkosten machen Verteidigung unbezahlbar** | 30 % ist ein Startwert, kein Beschluss. Er kommt mit der ersten gespielten Runde auf den Prüfstand |
## Fertig wenn
-1. `dotnet test tools/Nova.SimRunner.Tests` ist **in der CI** grün — ohne
+1. `./.dotnet/dotnet test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release --no-restore` ist lokal und **in der CI** grün — ohne
Baseline-Neusetzung im selben PR wie eine Verhaltensänderung.
2. Ein Mensch hat eine Runde gespielt und dabei gesehen:
- der Sammler fährt nach der ersten Raffinerie von allein los,
@@ -371,18 +399,21 @@ dann **16.6**. Jeder Abwurf mit Begründung in den
| D-096 | Lager erhält eine **abgeleitete** AE-Obergrenze (kein Zustandsfeld); Radar schaltet die Minimap frei und leitet seine Abdeckung vom Gebäude ab | Inhaber (Richtung) / Agent (Ausformung) |
| D-097 | „Stoppen" löscht den Angriffsbefehl; ein Halte-Feuer bleibt beim Einheitenstrang | Inhaber |
| D-102 | Fünf endliche, punktgespiegelte Aetheriumfelder; `HarvestRateAE` bleibt bis zur gespielten Kalibrierung bei 2 AE/Tick | Inhaber / Agent |
+| D-103 | Bauvoraussetzungen werden eine fraktionsgleiche All-of-Maske; unbekannte Bits scheitern geschlossen | Agent unter Inhaberdelegation |
| D-106 | AE-Kontobasis gilt einmalig je Slot; vorhandener Überhang zerfällt zustandslos pro Sekunde und die Regelrevision wird im Match-Fingerprint gebunden | Agent unter Inhaberdelegation |
-D-096, D-097, D-102 und D-106 sind im [DecisionLog](../DecisionLog.md) eingetragen. D-098
-(Entwurf) und D-099 stehen dort für [Sprint 17](17_Sprint_Zugangsprotokoll.md),
-D-100 bleibt für dessen Paket B vorgemerkt, D-098 gehört zu
-[Sprint 14](14_Sprint_Lobby.md). Keine dieser Nummern darf hier verbraucht
-werden.
+D-096, D-097, D-102, D-103 und D-106 sind im [DecisionLog](../DecisionLog.md)
+eingetragen. D-098 (Entwurf) und D-099 stehen dort für
+[Sprint 17](17_Sprint_Zugangsprotokoll.md); D-092 bis D-094 gehören zu
+[Sprint 14](14_Sprint_Lobby.md). D-100 wird hier mangels eigenem
+DecisionLog-Eintrag keinem Paket zugeordnet. Keine dieser Nummern darf hier
+verbraucht werden.
## Changelog-Notiz
Die Wirtschaft trägt sich selbst: Aetherium wird knapp, Lager begrenzt das Konto,
-Radar schaltet die Minimap frei, Strommangel schaltet Radar und Verteidigung ab,
+Radar schaltet die Minimap frei, Strommangel schaltet Radar und Minimap ab und
+halbiert Produktion, Bau und Reparatur,
Bauvoraussetzungen greifen mehrfach, Platzierung und Reparatur kosten. Dazu die
Betatest-Behebungen: der erste Sammler erntet von allein, Einheiten fahren aus
dem Gebäude, Baustellen schiessen nicht mehr, und der gesperrte Bauknopf nennt
@@ -399,6 +430,7 @@ kein Bruch.
| Version | Datum | Änderung | Autor |
|---|---|---|---|
+| 1.5.0 | 2026-08-10 | D-103 für Paket 16.8 ergänzt: fraktionsgleiche All-of-Voraussetzungen, fail-closed Maskenvertrag, Definitions-Hash-Grenze und koordinierter KI-Handoff | Agent (unter Delegation) / Dennis Westermann |
| 1.4.0 | 2026-08-10 | D-102 ergänzt: fünf endliche symmetrische Felder sind Paket 16.7; die Ernterate bleibt mangels gespielter Zielkurve ausdrücklich bei 2 AE/Tick und wird getrennt kalibriert | Project Owner / Agent |
| 1.3.0 | 2026-08-10 | C4-Kompatibilitätsgrenze dokumentiert: Low-Power-Reparatur bindet Rules-Revision 2 und 10/5 HP pro Tick, ohne Zustands- oder Schema-Bump | Codex / Dennis Westermann |
| 1.2.0 | 2026-08-10 | D-106 für 16.4 festgeschrieben: einmalige HQ-Kontobasis, periodischer 25-%-Abbau des aktuellen Überhangs und Rules-Hash-Kompatibilitätsgrenze | Codex / Dennis Westermann |
diff --git a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs
index ad99ff1..16e66aa 100644
--- a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs
+++ b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs
@@ -43,17 +43,17 @@ namespace Nova.SimRunner.Tests
[TestFixture]
public sealed class CanonicalAiOutcomeTests
{
- /// Decided tick of the canonical AI match, last moved by: Sprint 16.2 (#46). Previous value: 2548 (Sprint 16.1).
- private const uint PinnedDecidedTick = 2546u;
+ /// Decided tick of the canonical AI match, last moved by: Sprint 16.8 / D-103 (r7 prerequisite handoff). Previous value: 2546 (Sprint 16.4).
+ private const uint PinnedDecidedTick = 2705u;
///
/// End-state hash of the canonical AI match, last moved by: Sprint 16
- /// package 16.4 (#53) — storage capacity and excess decay change the
- /// economy the AI plays in. The AI itself is unchanged:
- /// AiBehaviorId stays r6.E34435F9.
- /// Previous value: 0x9F93097AD526B6F7 (Sprint 16.2).
+ /// package 16.8 / D-103. The all-of prerequisite contract and the r7
+ /// handoff add the required Power plant before the Barracks.
+ /// AiBehaviorId is r7.E34435F9.
+ /// Previous value: 0xE784E6184AD16081 (Sprint 16.4).
///
- private const string PinnedEndState = "0xE784E6184AD16081";
+ private const string PinnedEndState = "0x28F2CC571BCE6B76";
[Test]
public void CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState()
diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs
index 45e4782..f8f2d6a 100644
--- a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs
+++ b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs
@@ -149,6 +149,28 @@ public void Definitions_CoverBothFactions_WithTheDocumentedIdRule()
"the Refinery lost its Power-plant prerequisite in both factions (D-077); its draw is unchanged");
Assert.That(SimDefinitions.TryGetBuilding(FactionId.Legion, UnitRole.Refinery, out SimBuildingDefinition refineryL)
&& refineryL.PowerRequired == 15 && !refineryL.HasPrerequisite, Is.True);
+
+ var prerequisiteTable = new (UnitRole Role, UnitRoleMask Prerequisites)[]
+ {
+ (UnitRole.HQ, UnitRoleMask.None),
+ (UnitRole.Power, UnitRoleMask.HQ),
+ (UnitRole.Refinery, UnitRoleMask.None),
+ (UnitRole.Storage, UnitRoleMask.Refinery),
+ (UnitRole.Barracks, UnitRoleMask.HQ | UnitRoleMask.Power),
+ (UnitRole.VehicleFactory, UnitRoleMask.Refinery | UnitRoleMask.Barracks),
+ (UnitRole.ResearchLab, UnitRoleMask.VehicleFactory),
+ (UnitRole.Radar, UnitRoleMask.Power | UnitRoleMask.Barracks),
+ (UnitRole.DefensePlatform, UnitRoleMask.Power),
+ };
+ foreach (FactionId faction in new[] { FactionId.Alliance, FactionId.Legion })
+ {
+ foreach ((UnitRole role, UnitRoleMask prerequisites) in prerequisiteTable)
+ {
+ Assert.That(SimDefinitions.TryGetBuilding(faction, role, out SimBuildingDefinition def), Is.True);
+ Assert.That(def.PrerequisiteRoles, Is.EqualTo(prerequisites),
+ $"{faction} {role} prerequisite mask");
+ }
+ }
}
[Test]
@@ -199,6 +221,8 @@ public void ValidatePlacement_ForeignFactionDefinition_IsRejectedInvalidTarget()
// Legion one (id 24) — a known id naming unbuildable content is an
// invalid target, exactly like an unknown one.
var f = new Fixture(configure: e => e.SetSlotFaction(1, FactionId.Legion));
+ Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 36, 40).IsValid, Is.True,
+ "Legion HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(1, 22, 40, 40).IsValid, Is.True,
"Legion power provider so the power rule does not mask the faction check");
f.Step(1); // commit the balance
@@ -218,10 +242,11 @@ public void ValidatePlacement_ForeignFactionDefinition_IsRejectedInvalidTarget()
public void PlaceBuilding_LegionSlot_ChargesLegionCost_AndBuildsFaster()
{
var f = new Fixture(configure: e => e.SetSlotFaction(1, FactionId.Legion));
+ Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 36, 40).IsValid, Is.True, "Legion HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(1, 22, 40, 40).IsValid, Is.True, "Legion power provider");
f.SpawnBuilder(1, 19, 20);
f.Step(1); // commit the balance (Legion Power plant provides 80)
- Assert.That(f.Economy.GetPlayerEconomy(1).PowerProvided, Is.EqualTo(80),
+ Assert.That(f.Economy.GetPlayerEconomy(1).PowerProvided, Is.EqualTo(110),
"the power recompute is faction-resolved");
Assert.That(f.Construction.TryPlaceBuilding(1, 24, 20, 20), Is.True, "Legion Barracks def 24");
@@ -255,6 +280,7 @@ private static uint RawOfCompleted(Fixture f, byte slot, UnitRole role)
public void PlaceBuilding_ChargesExactCost_AndCreatesSiteEntity()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
f.SpawnBuilder(0, 19, 20);
f.Step(1); // commit the balance (phase-2 recompute)
@@ -298,6 +324,7 @@ public void PlaceBuilding_OccupiedOrOutOfMap_IsRejectedInvalidTarget()
var f = new Fixture();
f.SpawnBuilder(0, 19, 20);
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 20, 20).IsValid, Is.True, "Power plant at (20,20)");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True, "completed Refinery prerequisite");
f.Step(1); // commit the balance
Assert.That(f.Construction.ValidatePlacement(0, 6, 21, 21), Is.EqualTo(CommandResultCode.RejectedInvalidTarget),
@@ -311,16 +338,47 @@ public void PlaceBuilding_OccupiedOrOutOfMap_IsRejectedInvalidTarget()
}
[Test]
- public void PlaceBuilding_MissingPrerequisite_IsRejectedPrerequisitesNotMet()
+ public void PlaceBuilding_AllPrerequisitesAreRequired_AndUnknownBitsFailClosed()
{
var f = new Fixture();
f.SpawnBuilder(0, 19, 20);
- Assert.That(f.Construction.ValidatePlacement(0, 11, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet),
- "a DefensePlatform requires a completed own Power plant");
+ UnitRoleMask barracksPrerequisites = UnitRoleMask.HQ | UnitRoleMask.Power;
+ Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites),
+ Is.EqualTo(barracksPrerequisites));
+ Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet),
+ "Barracks requires both HQ and Power");
+
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True);
+ Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites),
+ Is.EqualTo(UnitRoleMask.Power), "HQ alone is insufficient");
+ Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet));
+
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True);
- f.Step(1); // commit the balance (100 provided)
- Assert.That(f.Construction.ValidatePlacement(0, 11, 20, 20), Is.EqualTo(CommandResultCode.Applied));
+ Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites), Is.EqualTo(UnitRoleMask.None));
+ Assert.That(f.Construction.HasFinishedBuildings(0, barracksPrerequisites), Is.True);
+ f.Step(1); // commit the balance (130 provided)
+ Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.Applied));
+
+ var onlyPower = new Fixture();
+ Assert.That(onlyPower.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True);
+ Assert.That(onlyPower.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites),
+ Is.EqualTo(UnitRoleMask.HQ), "Power alone is insufficient");
+
+ var foreign = new Fixture();
+ Assert.That(foreign.Construction.PlaceCompletedBuilding(1, 3, 40, 40).IsValid, Is.True);
+ Assert.That(foreign.Construction.GetMissingPrerequisiteRoles(0, UnitRoleMask.HQ),
+ Is.EqualTo(UnitRoleMask.HQ), "a foreign completed building does not satisfy the mask");
+
+ var unfinished = new Fixture(startingCredits: 3000);
+ unfinished.SpawnBuilder(0, 19, 20);
+ Assert.That(unfinished.Construction.TryPlaceBuilding(0, 3, 20, 20), Is.True, "own HQ site");
+ Assert.That(unfinished.Construction.GetMissingPrerequisiteRoles(0, UnitRoleMask.HQ),
+ Is.EqualTo(UnitRoleMask.HQ), "an own unfinished site does not satisfy the mask");
+
+ const UnitRoleMask unknownBit = (UnitRoleMask)(1u << 31);
+ Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, unknownBit), Is.EqualTo(unknownBit));
+ Assert.That(f.Construction.HasFinishedBuildings(0, unknownBit), Is.False, "unknown roles fail closed");
}
[Test]
@@ -328,15 +386,16 @@ public void PlaceBuilding_PowerRule_RequiresSufficientFreePower()
{
var f = new Fixture();
f.SpawnBuilder(0, 19, 20);
- // Committed balance: HQ 30 provided, Refinery 20 required -> 10 free.
+ // Committed balance: HQ 30 provided, completed VehicleFactory 25
+ // required -> 5 free. The factory satisfies the ResearchLab's
+ // prerequisite, so only the power gate can reject it.
Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True);
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 44, 40).IsValid, Is.True);
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 44, 40).IsValid, Is.True);
f.Step(1); // let the economy recompute the balance
- Assert.That(f.Construction.ValidatePlacement(0, 8, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet),
- "VehicleFactory draws 25 but only 10 are free");
- Assert.That(f.Construction.ValidatePlacement(0, 6, 20, 20), Is.EqualTo(CommandResultCode.Applied),
- "Storage draws 5 of the 10 free power");
+ Assert.That(f.Construction.HasFinishedBuildings(0, UnitRoleMask.VehicleFactory), Is.True);
+ Assert.That(f.Construction.ValidatePlacement(0, 9, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet),
+ "ResearchLab draws 30 but only 5 are free");
Assert.That(f.Construction.ValidatePlacement(0, 5, 60, 60), Is.EqualTo(CommandResultCode.Applied),
"power-providing buildings are exempt from the rule");
}
@@ -367,6 +426,7 @@ public void RefineryPlacement_NeedsNoPowerPlant_TheCommandPathEnforcesOnlyThePow
public void SiteProgress_RequiresBuilderInReach_PausesWhenAway()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
EntityId builder = f.SpawnBuilder(0, 60, 60); // far away
f.Step(1); // commit the balance
@@ -392,8 +452,10 @@ public void SiteProgress_RequiresBuilderInReach_PausesWhenAway()
public void SiteProgress_LowPower_ExactlyHalvesProgress()
{
var f = new Fixture();
- // Low power: a completed Refinery draws 20 with nothing provided.
+ // Low power: an HQ provides 30 while two completed Refineries draw 40.
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 48, 40).IsValid, Is.True);
Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True);
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 44, 40).IsValid, Is.True);
f.SpawnBuilder(0, 19, 20);
Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True, "Power plant def 5, 150 ticks");
f.Step(1);
@@ -417,13 +479,14 @@ public void SiteProgress_LowPower_ExactlyHalvesProgress()
Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Power),
"the plant completes after exactly 300 low-power ticks");
Assert.That(f.Construction.SiteCount, Is.EqualTo(0));
- Assert.That(f.Construction.BuildingCount, Is.EqualTo(2));
+ Assert.That(f.Construction.BuildingCount, Is.EqualTo(4));
}
[Test]
public void Completion_NormalizesLegacySiteRole_AndPowerAppliesFromNextTick()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ prerequisite");
f.SpawnBuilder(0, 19, 20);
Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True);
uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f));
@@ -439,10 +502,10 @@ public void Completion_NormalizesLegacySiteRole_AndPowerAppliesFromNextTick()
"completion normalizes legacy snapshot entities to their definition role");
Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).CurrentHealth, Is.EqualTo(400),
"completion restores full HP");
- Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(0),
+ Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(30),
"the economy ran before construction inside the completion tick");
f.Step(1);
- Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(100),
+ Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(130),
"power applies from the next economy recompute on");
}
@@ -451,7 +514,7 @@ public void ResearchLabCompletion_UnlocksT2()
{
var f = new Fixture(startingCredits: 3000);
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); // power
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 7, 44, 40).IsValid, Is.True); // barracks prerequisite
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 44, 40).IsValid, Is.True); // VehicleFactory prerequisite
f.SpawnBuilder(0, 19, 20);
f.Step(1);
@@ -510,18 +573,20 @@ public void Site_CarriesDefinitionRole_ButDrawsAndProvidesNoPower_UntilCompletio
public void PowerSite_ProvidesNothing_UntilCompletion()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True,
+ "the completed HQ satisfies the Power-plant prerequisite and provides 30 power");
f.SpawnBuilder(0, 19, 20);
f.Step(1);
Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True, "Power plant def 5 (feeds 100 completed)");
f.Step(1);
- Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(0),
- "a Power site must not power itself up mid-build");
+ Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(30),
+ "a Power site must not add to the completed-HQ baseline mid-build");
f.Step(150); // completion (150 full-power ticks)
f.Step(1); // next economy recompute
- Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(100),
- "the completed plant feeds its 100");
+ Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(130),
+ "the completed plant adds its 100 to the HQ's 30");
}
private static int CountUnits(Fixture f, byte slot, UnitRole role)
@@ -696,8 +761,8 @@ public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged(
public void CancelConstruction_Refunds75Percent_AndFreesFootprint()
{
var f = new Fixture();
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True,
- "HQ provides power and 2,000 AE capacity");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
f.SpawnBuilder(0, 19, 20);
f.Step(1); // commit the balance
Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 500 spent
@@ -720,19 +785,19 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint()
public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable()
{
var f = new Fixture();
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True,
- "HQ provides power and 2,000 AE capacity");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20);
uint raw = UnitCommandStateView.ToRawEntityId(barracks);
Assert.That(f.Construction.SellBuilding(raw), Is.True);
Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1250L),
"1000 + 250 (50% floor, provisional)");
- Assert.That(f.Construction.BuildingCount, Is.EqualTo(1), "only the Barracks was sold");
+ Assert.That(f.Construction.BuildingCount, Is.EqualTo(2), "only the Barracks was sold");
Assert.That(f.Construction.IsCellFree(20, 20), Is.True);
f.SpawnBuilder(0, 19, 20);
- f.Step(1); // commit the balance (30 provided, 0 required)
+ f.Step(1); // commit the balance (130 provided, 0 required)
Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True);
uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f));
Assert.That(f.Construction.ValidateSell(0, siteRaw), Is.EqualTo(CommandResultCode.RejectedInvalidTarget),
@@ -743,8 +808,10 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable()
public void CancelConstruction_RefundIsCappedAtStorageCeiling()
{
var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE);
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True,
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True,
"HQ provides power and the 2,000 AE ceiling");
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True,
+ "the completed Power plant satisfies the Barracks prerequisite");
f.SpawnBuilder(0, 19, 20);
f.Step(1);
Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 2.000 - 500 = 1.500
@@ -852,6 +919,7 @@ public void Repair_Validation_RejectsNonBuilder_AndUndamagedTarget()
public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
f.SpawnBuilder(0, 19, 20);
f.Step(1); // commit the balance
@@ -870,12 +938,13 @@ public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint()
public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ prerequisite");
EntityId builder = f.SpawnBuilder(0, 19, 20);
Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True);
EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 30, 30);
f.Entities.GetUnitRef(barracks).CurrentHealth = 100;
f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(builder), UnitCommandStateView.ToRawEntityId(barracks));
- Assert.That(f.Construction.PlaceCompletedBuilding(0, 9, 40, 40).IsValid, Is.True); // T2 flag
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 9, 44, 40).IsValid, Is.True); // T2 flag
f.Step(10); // accumulate some site progress
var writer = new SnapshotBlockWriter();
@@ -905,6 +974,7 @@ public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected()
public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
f.SpawnBuilder(0, 19, 20);
EntityId soldier = f.Entities.SpawnUnit(
@@ -942,6 +1012,7 @@ public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation()
public void ProgressSites_ReassignsNonBuilderAssignment_DefenseInDepth()
{
var f = new Fixture();
+ Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite");
Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider");
EntityId builder = f.SpawnBuilder(0, 19, 20);
f.Step(1);
diff --git a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs
index c738eea..51764b7 100644
--- a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs
+++ b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs
@@ -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.
diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs
index e8618cb..7bd448b 100644
--- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs
+++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs
@@ -51,7 +51,7 @@ public sealed class SkirmishAiTests
///
/// 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.
///
internal const int EndToEndBudgetTicks = 6000;
@@ -312,16 +312,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");
@@ -340,12 +355,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;
@@ -497,7 +513,7 @@ public void AiBehaviorId_TracksWhichAiThisIs()
// outcome unmoved is exactly the "declared change, no effect yet"
// case — under the old coupled pin it was indistinguishable from a
// simulation change.
- Assert.That(AiBehaviorId.Value, Is.EqualTo("r6.E34435F9"),
+ Assert.That(AiBehaviorId.Value, Is.EqualTo("r7.E34435F9"),
"the AI identifier changed — bump the revision and write the journal entry");
}
diff --git a/tools/Nova.SimRunner.Tests/VictorySystemTests.cs b/tools/Nova.SimRunner.Tests/VictorySystemTests.cs
index 4cd2e54..5f9ecb6 100644
--- a/tools/Nova.SimRunner.Tests/VictorySystemTests.cs
+++ b/tools/Nova.SimRunner.Tests/VictorySystemTests.cs
@@ -31,9 +31,9 @@ public sealed class VictorySystemTests
private const int Capacity = 64;
private const ushort MapSize = 64;
- /// Power plant / Barracks definition ids (SimDefinitions MS-1 table).
+ /// Power plant / DefensePlatform definition ids (SimDefinitions MS-1 table).
private const ushort DefPower = 5;
- private const ushort DefBarracks = 7;
+ private const ushort DefDefensePlatform = 11;
///
/// Minimal canonical host: the systems the victory contract actually
@@ -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.
diff --git a/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs b/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs
index fe86bb3..db650f8 100644
--- a/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs
+++ b/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs
@@ -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()
{
@@ -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);
}
@@ -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);