From 54b81605a3162988f174784fe55596a02cffbf51 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 23:12:02 +0200 Subject: [PATCH] feat(construction): enforce all building prerequisites --- .../Simulation/ConstructionSystemTests.cs | 107 ++++++++++++++---- .../EditMode/Simulation/VictorySystemTests.cs | 12 +- .../EditMode/Simulation/WeaponValuesTests.cs | 21 +++- .../Scripts/Presentation/UI/BuildMenuHud.cs | 42 +++++-- .../Construction/ConstructionSystem.cs | 46 ++++++-- .../Simulation/Definitions/SimDefinitions.cs | 75 +++++++----- CHANGELOG.md | 7 ++ docs/gamedesign/Buildings.md | 38 +++++-- docs/production/DecisionLog.md | 61 +++++++++- .../hashkrieg/16_Sprint_Wirtschaft.md | 44 +++++-- .../ConstructionSystemTests.cs | 107 ++++++++++++++---- .../VictorySystemTests.cs | 12 +- .../Nova.SimRunner.Tests/WeaponValuesTests.cs | 21 +++- 13 files changed, 475 insertions(+), 118 deletions(-) diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index e6b6b74..cc63ded 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) @@ -297,6 +323,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, "Storage prerequisite"); f.Step(1); // commit the balance Assert.That(f.Construction.ValidatePlacement(0, 6, 21, 21), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), @@ -310,16 +337,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] @@ -327,15 +385,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"); } @@ -366,6 +425,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 @@ -391,8 +451,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); @@ -413,13 +475,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_BecomesRoleEntity_PowerAppliesFromNextTick() { 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)); @@ -430,10 +493,10 @@ public void Completion_BecomesRoleEntity_PowerAppliesFromNextTick() Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Power)); 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"); } @@ -442,7 +505,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); @@ -634,6 +697,7 @@ public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged( public void CancelConstruction_Refunds75Percent_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 @@ -657,6 +721,7 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint() public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() { 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 barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); @@ -664,7 +729,7 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() 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); @@ -727,6 +792,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 @@ -745,12 +811,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(); @@ -780,6 +847,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( @@ -817,6 +885,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/VictorySystemTests.cs b/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs index 45502b7..542196f 100644 --- a/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs @@ -32,9 +32,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 @@ -371,13 +371,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 adf6bbb..99b0563 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/Presentation/UI/BuildMenuHud.cs b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs index d1163e6..cba83b2 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 669e01e..ce4dd18 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -289,21 +289,47 @@ public bool IsCompletedPlacement(uint rawEntityId) return IndexOfBuilding(rawEntityId) >= 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; } // ------------------------------------------------------------------ @@ -314,7 +340,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. @@ -337,7 +363,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..e229570 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 = @@ -540,7 +559,7 @@ public static bool IsBuildingRole(UnitRole role) /// 21-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 f149aaf..a703324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,13 @@ 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. - **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 af1262a..3970341 100644 --- a/docs/gamedesign/Buildings.md +++ b/docs/gamedesign/Buildings.md @@ -1,6 +1,6 @@ # Gebäude – alle Fraktionen -**Version:** 0.5.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 4 +**Version:** 0.6.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) +- [../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-103 (MS-1-Bauvoraussetzungen) - [./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 @@ -267,3 +286,4 @@ Entschieden und entfernt im Korrekturlauf Sprint 4: HQ-Grundenergie (+30 führen | 0.4.0 | 2026-07-21 | Korrekturlauf Sprint 4 (D-043–D-052, Review-Findings): als führende Quelle für Gebäudekosten/-energie/-bauzeiten festgelegt (Review F-03, D-047-Grundsatzregel); Offene Punkte bereinigt | Lead Gameplay Designer | | 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-09 | D-077-Startzustand berichtigt und die neun All-of-Platzierungsvoraussetzungen samt Trennung von Plattformbasis und Modulen gemäß D-103 festgeschrieben | Agent (unter Delegation) | diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md index 4c4e8b5..321f7b6 100644 --- a/docs/production/DecisionLog.md +++ b/docs/production/DecisionLog.md @@ -1,6 +1,6 @@ # Decision Log -**Version:** 1.33.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 16 +**Version:** 1.35.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 16 ## Zweck @@ -2992,6 +2992,64 @@ die Zusicherung dort schrumpft auf eine Zeile, der erklärende Kommentar verweist auf die neue Datei. Der Einheitenstrang ist zu informieren (Issue #75). Wer den Harness umbenennt, sagt es an. Keine Baseline-Datei ist berührt. +--- + +### 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. +Dieser Eingriff liegt im extern verwalteten `Scripts/AI*`-Bereich und erfolgt +koordiniert als eigener Handoff; Paket 16.8 wird ohne dessen 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. + ## Offene Punkte - Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden. @@ -3072,6 +3130,7 @@ Wer den Harness umbenennt, sagt es an. Keine Baseline-Datei ist berührt. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.35.0 | 2026-08-09 | 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) | | 1.33.0 | 2026-08-09 | D-101 aufgenommen: der Ausgangspin der kanonischen KI-Partie (Entscheidungstick, Endzustand) wird vom Identitätspin getrennt und zieht in eine Maintainer-Datei; `tools/Nova.SimRunner.Tests/` bekommt erstmals eine Eigentümerzeile | Project Owner / Orchestrator | | 1.0.0 | 2026-07-21 | D-001 bis D-005 aus Sprint 0 protokolliert | Game Director | | 1.1.0 | 2026-07-21 | D-006 (Unity 6.3 LTS + URP bestätigt) aus Sprint-1-Validierung | Lead Technical Director | diff --git a/docs/production/hashkrieg/16_Sprint_Wirtschaft.md b/docs/production/hashkrieg/16_Sprint_Wirtschaft.md index 8b02f2d..21ac15e 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.0.0 | **Status:** geplant | **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.2.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 @@ -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 | @@ -220,14 +220,37 @@ Der Definitions-Hash bewegt sich hier **nicht** — das tut nur 16.8. ### 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 plant bislang Raffinerie → Kaserne. Das neue +Kraftwerk-Tor verlangt den koordinierten, getrennten Handoff im fremden +`Scripts/AI*`-Schreibbereich; 16.8 wird erst nach dessen grünem Integrationslauf +gemergt. ### 16.9 · Platzierungsregeln und Reparaturkosten (C6) @@ -286,7 +309,7 @@ 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) | @@ -355,3 +378,4 @@ Die Baseline-Neusetzung ist Zweck der Tests, kein Bruch. | Version | Datum | Änderung | Autor | |---|---|---|---| | 1.0.0 | 2026-08-09 | Erstfassung: Strang C aus Sprint 12 und die acht Betatest-Befunde im selben Schreibbereich zu einem Sprint zusammengeführt, am Code geprüft und nach Kosten sortiert | Orchestrator | +| 1.2.0 | 2026-08-09 | Paket 16.8 mit D-103 konkretisiert: identische All-of-Kette für beide Fraktionen, fail-closed Maskenvertrag, Definitions-Hash-Folge und KI-Handoff festgeschrieben | Agent (unter Delegation) | diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index e1c380a..283d77c 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) @@ -297,6 +323,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, "Storage prerequisite"); f.Step(1); // commit the balance Assert.That(f.Construction.ValidatePlacement(0, 6, 21, 21), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), @@ -310,16 +337,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] @@ -327,15 +385,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"); } @@ -366,6 +425,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 @@ -391,8 +451,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); @@ -413,13 +475,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_BecomesRoleEntity_PowerAppliesFromNextTick() { 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)); @@ -430,10 +493,10 @@ public void Completion_BecomesRoleEntity_PowerAppliesFromNextTick() Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Power)); 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"); } @@ -442,7 +505,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); @@ -634,6 +697,7 @@ public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged( public void CancelConstruction_Refunds75Percent_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 @@ -657,6 +721,7 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint() public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() { 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 barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); @@ -664,7 +729,7 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() 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); @@ -727,6 +792,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 @@ -745,12 +811,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(); @@ -780,6 +847,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( @@ -817,6 +885,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/VictorySystemTests.cs b/tools/Nova.SimRunner.Tests/VictorySystemTests.cs index 9d546bd..ea05d67 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 @@ -370,13 +370,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 9514b51..b586643 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);