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);