From 56ea3a57041ee40bce40e3095f0ee35eabe26fe5 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 23:49:01 +0200 Subject: [PATCH] feat(construction): enforce placement and repair rules --- .../Simulation/ConstructionSystemTests.cs | 385 ++++++++++++++++-- .../ProductionConstructionIntegrationTests.cs | 40 +- .../EditMode/Simulation/VictorySystemTests.cs | 2 +- .../Construction/ConstructionSystem.cs | 273 +++++++++++-- .../Simulation/Economy/EconomySystem.cs | 18 + CHANGELOG.md | 5 + docs/gamedesign/ArmorSystem.md | 8 +- docs/gamedesign/Economy.md | 7 +- docs/production/DecisionLog.md | 68 +++- docs/production/OpenQuestions.md | 5 +- .../hashkrieg/16_Sprint_Wirtschaft.md | 38 +- .../CanonicalAiOutcomeTests.cs | 10 +- .../ConstructionSystemTests.cs | 385 ++++++++++++++++-- .../LockstepNetworkTests.cs | 2 +- .../ProductionConstructionIntegrationTests.cs | 40 +- .../VictorySystemTests.cs | 2 +- 16 files changed, 1145 insertions(+), 143 deletions(-) diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index e6b6b74..bb66b67 100644 --- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs @@ -28,20 +28,29 @@ private sealed class Fixture { public EntityManager Entities { get; } public EconomySystem Economy { get; } + public CostField CostField { get; } public ConstructionSystem Construction { get; } public SimulationKernel Kernel { get; } - public Fixture(long startingCredits = 1000, System.Action configure = null) + public Fixture( + long startingCredits = 1000, + System.Action configure = null, + bool addDefaultField = true) { Entities = new EntityManager(64); Economy = new EconomySystem(Entities, startingCredits); - Construction = new ConstructionSystem(Entities, Economy); + CostField = new CostField(ConstructionSystem.GridSize, ConstructionSystem.GridSize); + Construction = new ConstructionSystem(Entities, Economy, CostField); Kernel = new SimulationKernel(new SimRandom(42UL)); Kernel.RegisterSystem(Economy); Kernel.RegisterSystem(Construction); // Pre-start configuration hook (e.g. slot factions): the // SetSlotFaction guard locks the assignment at Kernel.Start(). configure?.Invoke(Economy); + if (addDefaultField && Economy.FieldCount == 0) + { + Economy.TryAddField(63, new GridPos2D(20, 24), 9000); + } Kernel.Start(); } @@ -199,7 +208,7 @@ 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, 22, 40, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(1, 22, 26, 20).IsValid, Is.True, "Legion power provider so the power rule does not mask the faction check"); f.Step(1); // commit the balance f.SpawnBuilder(0, 19, 20); @@ -218,7 +227,7 @@ 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, 22, 40, 40).IsValid, Is.True, "Legion power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(1, 22, 26, 20).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), @@ -255,7 +264,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, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); f.SpawnBuilder(0, 19, 20); f.Step(1); // commit the balance (phase-2 recompute) @@ -283,6 +292,7 @@ public void PlaceBuilding_ChargesExactCost_AndCreatesSiteEntity() public void PlaceBuilding_InsufficientFunds_FailsAndMutatesNothing() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "influence anchor"); f.SpawnBuilder(0, 19, 20); Assert.That(f.Construction.TryPlaceBuilding(0, 3, 20, 20), Is.False, "HQ costs 2500 (Buildings.md), balance is 1000"); @@ -309,15 +319,149 @@ public void PlaceBuilding_OccupiedOrOutOfMap_IsRejectedInvalidTarget() "an unknown definition id is an invalid target, not a cost failure"); } + [Test] + public void ValidatePlacement_RequiresEveryFootprintCellToBeWalkable() + { + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 12, 20).IsValid, Is.True, "influence anchor"); + + f.CostField.SetCost(22, 22, 254); + Assert.That(f.Construction.ValidatePlacement(0, 5, 20, 20), Is.EqualTo(CommandResultCode.Applied), + "rough terrain costs 1 through 254 stay walkable"); + + f.CostField.SetCost(22, 22, CostField.ImpassableCost); + Assert.That(f.Construction.ValidatePlacement(0, 5, 20, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "one impassable cell rejects the whole 3x3 footprint"); + } + + [Test] + public void ValidatePlacement_InfluenceUsesOwnLivingCompletedFootprints_AtDistanceEight() + { + var own = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + Assert.That(own.Construction.PlaceCompletedBuilding(0, 5, 10, 10).IsValid, Is.True); + Assert.That(own.Construction.ValidatePlacement(0, 5, 20, 10), Is.EqualTo(CommandResultCode.Applied), + "footprint distance 8 is inside influence"); + Assert.That(own.Construction.ValidatePlacement(0, 5, 21, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "footprint distance 9 is outside influence"); + + var enemy = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + Assert.That(enemy.Construction.PlaceCompletedBuilding(1, 5, 10, 10).IsValid, Is.True); + Assert.That(enemy.Construction.ValidatePlacement(0, 5, 20, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "an enemy completed anchor never supplies influence"); + + var dead = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + EntityId deadAnchor = dead.Construction.PlaceCompletedBuilding(0, 5, 10, 10); + Assert.That(dead.Entities.DespawnUnit(deadAnchor), Is.True); + Assert.That(dead.Construction.ValidatePlacement(0, 5, 20, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "a dead completed-table entry is not a living anchor"); + + var siteOnly = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + EntityId originalAnchor = siteOnly.Construction.PlaceCompletedBuilding(0, 3, 0, 10); + Assert.That(siteOnly.Construction.TryPlaceBuilding(0, 5, 10, 10), Is.True, "create an active Power site"); + Assert.That(siteOnly.Entities.DespawnUnit(originalAnchor), Is.True, "remove the only completed anchor"); + Assert.That(siteOnly.Construction.ValidatePlacement(0, 5, 20, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "an active site supplies spacing, never construction influence"); + } + + [Test] + public void ValidatePlacement_RequiresOneEmptyRingAroundBuildingsAndSites() + { + var buildings = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + Assert.That(buildings.Construction.PlaceCompletedBuilding(0, 5, 10, 10).IsValid, Is.True); + Assert.That(buildings.Construction.ValidatePlacement(0, 5, 13, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "edge-adjacent footprints have distance 1"); + Assert.That(buildings.Construction.ValidatePlacement(0, 5, 13, 13), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "diagonally adjacent footprints also have distance 1"); + Assert.That(buildings.Construction.ValidatePlacement(0, 5, 14, 10), Is.EqualTo(CommandResultCode.Applied), + "one empty cardinal ring gives distance 2"); + Assert.That(buildings.Construction.ValidatePlacement(0, 5, 14, 14), Is.EqualTo(CommandResultCode.Applied), + "one empty diagonal ring gives distance 2"); + + var sites = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + Assert.That(sites.Construction.PlaceCompletedBuilding(0, 5, 10, 10).IsValid, Is.True); + Assert.That(sites.Construction.TryPlaceBuilding(0, 5, 14, 10), Is.True, "site at legal distance 2"); + Assert.That(sites.Construction.ValidatePlacement(0, 5, 17, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "an active site enforces the same empty ring"); + Assert.That(sites.Construction.ValidatePlacement(0, 5, 18, 10), Is.EqualTo(CommandResultCode.Applied)); + } + + [Test] + public void ValidatePlacement_EnforcesRoleSpecificFieldDistances() + { + var distanceZero = PlacementFixtureWithField(20, 20); + Assert.That(distanceZero.Construction.ValidatePlacement(0, 4, 18, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget)); + + var distanceOne = PlacementFixtureWithField(20, 20); + Assert.That(distanceOne.Construction.ValidatePlacement(0, 4, 17, 20), Is.EqualTo(CommandResultCode.Applied), + "Refinery distance 1 is legal"); + Assert.That(distanceOne.Construction.ValidatePlacement(0, 5, 17, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "every other role rejects field distance 1"); + + var distanceTwo = PlacementFixtureWithField(20, 20); + Assert.That(distanceTwo.Construction.ValidatePlacement(0, 5, 16, 20), Is.EqualTo(CommandResultCode.Applied), + "every non-Refinery accepts field distance 2"); + + var distanceThree = PlacementFixtureWithField(20, 20); + Assert.That(distanceThree.Construction.ValidatePlacement(0, 4, 15, 20), Is.EqualTo(CommandResultCode.Applied), + "Refinery distance 3 is legal"); + + var distanceFour = PlacementFixtureWithField(20, 20); + Assert.That(distanceFour.Construction.ValidatePlacement(0, 4, 14, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "Refinery distance 4 is outside its required field band"); + + var noField = new Fixture(addDefaultField: false); + Assert.That(noField.Construction.PlaceCompletedBuilding(0, 5, 8, 20).IsValid, Is.True); + Assert.That(noField.Construction.ValidatePlacement(0, 4, 16, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "a Refinery needs at least one registered field"); + } + + [Test] + public void ValidatePlacement_ExhaustedFieldsRemainPermanentSpacingFeatures() + { + var f = PlacementFixtureWithField(20, 20, reserveAE: 1); + EntityId harvester = f.Entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(20), SimFixed.FromInt(20)), + SimFixed.FromInt(2), + role: UnitRole.Harvester); + f.Entities.GetUnitRef(harvester).HarvestFieldId = 1; + f.Step(1); + Assert.That(f.Economy.TryGetField(1, out AetheriumField field) && field.IsExhausted, Is.True); + Assert.That(f.Construction.ValidatePlacement(0, 5, 17, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "exhaustion changes reserve, not the field's permanent map cell"); + } + + [Test] + public void PlaceCompletedBuilding_BypassesGameplayPlacementGeometry() + { + var f = new Fixture(addDefaultField: false); + f.CostField.SetCost(20, 20, CostField.ImpassableCost); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 20, 20).IsValid, Is.True, + "deterministic setup bypasses terrain, influence and field-distance validation"); + } + + private static Fixture PlacementFixtureWithField(int fieldX, int fieldY, long reserveAE = 9000) + { + var f = new Fixture( + configure: economy => Assert.That( + economy.TryAddField(1, new GridPos2D(fieldX, fieldY), reserveAE), + Is.True), + addDefaultField: false); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 8, 20).IsValid, Is.True, "influence anchor"); + f.Step(1); + return f; + } + [Test] public void PlaceBuilding_MissingPrerequisite_IsRejectedPrerequisitesNotMet() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 26, 20).IsValid, Is.True, "non-Power influence anchor"); 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"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 30, 20).IsValid, Is.True); f.Step(1); // commit the balance (100 provided) Assert.That(f.Construction.ValidatePlacement(0, 11, 20, 20), Is.EqualTo(CommandResultCode.Applied)); } @@ -328,7 +472,7 @@ public void PlaceBuilding_PowerRule_RequiresSufficientFreePower() var f = new Fixture(); f.SpawnBuilder(0, 19, 20); // Committed balance: HQ 30 provided, Refinery 20 required -> 10 free. - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True); Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 44, 40).IsValid, Is.True); f.Step(1); // let the economy recompute the balance @@ -336,7 +480,7 @@ public void PlaceBuilding_PowerRule_RequiresSufficientFreePower() "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.ValidatePlacement(0, 5, 60, 60), Is.EqualTo(CommandResultCode.Applied), + Assert.That(f.Construction.ValidatePlacement(0, 5, 20, 20), Is.EqualTo(CommandResultCode.Applied), "power-providing buildings are exempt from the rule"); } @@ -348,7 +492,7 @@ public void RefineryPlacement_NeedsNoPowerPlant_TheCommandPathEnforcesOnlyThePow // factions. With a completed HQ (30 provided, covering the 20 // draw) the command path accepts it directly — the classic loop // start needs no Power plant first. - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ provides 30"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "HQ provides 30"); f.Step(1); // commit the balance Assert.That(f.Construction.ValidatePlacement(0, 4, 20, 20), Is.EqualTo(CommandResultCode.Applied), "no Power plant required (D-077)"); @@ -359,14 +503,14 @@ public void RefineryPlacement_NeedsNoPowerPlant_TheCommandPathEnforcesOnlyThePow f.Step(1); // commit 30 provided / 20 required // ... while the command path keeps enforcing it: the 10 free // power cannot cover a third Refinery's 20. - Assert.That(f.Construction.ValidatePlacement(0, 4, 30, 30), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet)); + Assert.That(f.Construction.ValidatePlacement(0, 4, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet)); } [Test] public void SiteProgress_RequiresBuilderInReach_PausesWhenAway() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); EntityId builder = f.SpawnBuilder(0, 60, 60); // far away f.Step(1); // commit the balance Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); @@ -393,6 +537,8 @@ public void SiteProgress_LowPower_ExactlyHalvesProgress() var f = new Fixture(); // Low power: a completed Refinery draws 20 with nothing provided. Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 6, 26, 20).IsValid, Is.True, + "Storage supplies influence without adding power"); 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 +559,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(3)); } [Test] public void Completion_BecomesRoleEntity_PowerAppliesFromNextTick() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "HQ influence and 30 power"); f.SpawnBuilder(0, 19, 20); Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True); uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); @@ -430,10 +577,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"); } @@ -441,7 +588,7 @@ public void Completion_BecomesRoleEntity_PowerAppliesFromNextTick() 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, 5, 26, 20).IsValid, Is.True); // power Assert.That(f.Construction.PlaceCompletedBuilding(0, 7, 44, 40).IsValid, Is.True); // barracks prerequisite f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -481,7 +628,7 @@ public void RefineryCompletion_GrantsTheFirstHarvesterFree() // down below 700 before the Refinery finishes can never earn // again — no Harvester, no Aetherium, no money for a Harvester. var f = new Fixture(startingCredits: 1000); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "HQ provides the 30 power the Refinery draws from"); f.SpawnBuilder(0, 19, 20); f.Step(1); // commit the balance @@ -511,10 +658,10 @@ public void RefineryCompletion_GrantedHarvester_StartsWithNearestFieldOrder() // the Refinery's footprint centre, ties resolved by index. var f = new Fixture(startingCredits: 1000, configure: eco => { - Assert.That(eco.TryAddField(1, new GridPos2D(30, 30), 9000), Is.True); + Assert.That(eco.TryAddField(1, new GridPos2D(20, 24), 9000), Is.True); Assert.That(eco.TryAddField(2, new GridPos2D(60, 60), 9000), Is.True); }); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ power"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "HQ power"); f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -523,7 +670,7 @@ public void RefineryCompletion_GrantedHarvester_StartsWithNearestFieldOrder() Assert.That(TryFindHarvester(f, 0, out UnitState harvester), Is.True, "the grant happened"); Assert.That(harvester.HarvestFieldId, Is.EqualTo(1), - "field 1 at (30,30) is closer to the footprint centre (21,21) than field 2 at (60,60)"); + "field 1 at (20,24) is closer to the footprint centre (21,21) than field 2 at (60,60)"); f.Step(50); Assert.That(TryFindHarvester(f, 0, out harvester), Is.True); @@ -534,17 +681,28 @@ public void RefineryCompletion_GrantedHarvester_StartsWithNearestFieldOrder() [Test] public void RefineryCompletion_WithoutFields_GrantedHarvesterCarriesNoOrder() { - var f = new Fixture(startingCredits: 1000); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); + var f = new Fixture(startingCredits: 1000, configure: eco => + { + Assert.That(eco.TryAddField(1, new GridPos2D(20, 24), 1), Is.True); + }); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True); f.SpawnBuilder(0, 19, 20); + EntityId temporaryHarvester = f.Entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(20), SimFixed.FromInt(24)), + SimFixed.FromInt(2), + role: UnitRole.Harvester); + f.Entities.GetUnitRef(temporaryHarvester).HarvestFieldId = 1; f.Step(1); + Assert.That(f.Economy.TryGetField(1, out AetheriumField field) && field.IsExhausted, Is.True); + Assert.That(f.Entities.DespawnUnit(temporaryHarvester), Is.True); Assert.That(f.Construction.TryPlaceBuilding(0, 4, 20, 20), Is.True); f.Step(250); Assert.That(TryFindHarvester(f, 0, out UnitState harvester), Is.True); Assert.That(harvester.HarvestFieldId, Is.EqualTo(0), - "no field registered: the grant still happens, only the order is skipped"); + "only exhausted fields remain: the grant still happens, only the order is skipped"); } [Test] @@ -553,9 +711,13 @@ public void RefineryCompletion_SecondRefinery_GrantsNothingWhileAHarvesterLives( // #43 latch: the grant is derived from the unit store — a second // Refinery (or a rebuild) grants nothing while any own Harvester // lives. Before 16.1 EVERY completed Refinery handed one out. - var f = new Fixture(startingCredits: 3000); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 44, 40).IsValid, Is.True, + var f = new Fixture(startingCredits: 3000, configure: economy => + { + Assert.That(economy.TryAddField(1, new GridPos2D(20, 24), 9000), Is.True); + Assert.That(economy.TryAddField(2, new GridPos2D(29, 24), 9000), Is.True); + }); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "HQ"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 30, 20).IsValid, Is.True, "power plant: two Refineries overdraw the HQ's 30 alone"); EntityId builderOne = f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -581,9 +743,13 @@ public void RefineryCompletion_AfterLosingEveryHarvester_TheGrantReArms() // The latch is the dead-end insurance, not a once-per-match // counter: with every Harvester lost the next completed Refinery // grants again. - var f = new Fixture(startingCredits: 3000); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 44, 40).IsValid, Is.True); + var f = new Fixture(startingCredits: 3000, configure: economy => + { + Assert.That(economy.TryAddField(1, new GridPos2D(20, 24), 9000), Is.True); + Assert.That(economy.TryAddField(2, new GridPos2D(29, 24), 9000), Is.True); + }); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 30, 20).IsValid, Is.True); EntityId builderOne = f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -634,7 +800,7 @@ public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged( public void CancelConstruction_Refunds75Percent_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).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 @@ -657,7 +823,7 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint() public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); @@ -680,8 +846,10 @@ public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { var f = new Fixture(); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "full-power repair rate"); uint raw = UnitCommandStateView.ToRawEntityId(barracks); - f.Entities.GetUnitRef(barracks).CurrentHealth = 100; + f.Entities.GetUnitRef(barracks).CurrentHealth = 0; + f.Step(1); EntityId farBuilder = f.SpawnBuilder(0, 60, 60); uint farRaw = UnitCommandStateView.ToRawEntityId(farBuilder); @@ -689,16 +857,160 @@ public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() "validation checks role and damage, not reach"); f.Construction.AssignRepairOrder(farRaw, raw); f.Step(10); - Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(100), + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(0), "out of reach: the order is held, not dropped"); f.Entities.GetUnitRef(farBuilder).Transform = new Transform2D(SimFixed.FromInt(19), SimFixed.FromInt(20)); f.Step(10); - Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(200), + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(100), "10 HP per tick in reach (provisional rate)"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(975L), + "S(100)-S(0) charges exactly 25 AE"); f.Step(50); Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(600), "repair caps at MaxHealth and the order resolves"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(850L), + "repairing the full 0..Max health scale costs floor(500*30/100)=150 AE"); + f.Entities.GetUnitRef(barracks).CurrentHealth = 590; + f.Step(1); + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(590), + "the order was removed when full and does not silently re-arm"); + } + + [Test] + public void Repair_CumulativeFloorTelescopesFromOddHealth() + { + var f = new Fixture(); + EntityId power = f.Construction.PlaceCompletedBuilding(0, 5, 20, 20); + f.Entities.GetUnitRef(power).CurrentHealth = 37; + EntityId builder = f.SpawnBuilder(0, 19, 20); + f.Step(1); + f.Construction.AssignRepairOrder( + UnitCommandStateView.ToRawEntityId(builder), + UnitCommandStateView.ToRawEntityId(power)); + + f.Step(37); + + Assert.That(f.Entities.GetUnitRef(power).CurrentHealth, Is.EqualTo(400)); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(877L), + "135 - floor(135*37/400) = 123 AE with no per-tick rounding drift"); + } + + [Test] + public void Repair_LowPowerUsesFiveHp_AndZeroPriceBandStillHeals() + { + var f = new Fixture(configure: economy => economy.SetSlotFaction(0, FactionId.Legion)); + EntityId defense = f.Construction.PlaceCompletedBuilding(0, 28, 20, 20); + f.Entities.GetUnitRef(defense).CurrentHealth = 6; + EntityId builder = f.SpawnBuilder(0, 19, 20); + f.Step(1); + Assert.That(f.Economy.GetPlayerEconomy(0).IsLowPower, Is.True); + f.Construction.AssignRepairOrder( + UnitCommandStateView.ToRawEntityId(builder), + UnitCommandStateView.ToRawEntityId(defense)); + + f.Step(1); + + Assert.That(f.Entities.GetUnitRef(defense).CurrentHealth, Is.EqualTo(11), "low power halves 10 HP to 5 HP"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1000L), + "S(6) and S(11) are both 1 AE, so the zero-price floor band still heals"); + + f.Step(100); + Assert.That(f.Entities.GetUnitRef(defense).CurrentHealth, Is.EqualTo(510)); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(911L)); + + var fullPower = new Fixture(configure: economy => economy.SetSlotFaction(0, FactionId.Legion)); + EntityId fullPowerDefense = fullPower.Construction.PlaceCompletedBuilding(0, 28, 20, 20); + Assert.That(fullPower.Construction.PlaceCompletedBuilding(0, 22, 26, 20).IsValid, Is.True); + fullPower.Entities.GetUnitRef(fullPowerDefense).CurrentHealth = 6; + EntityId fullPowerBuilder = fullPower.SpawnBuilder(0, 19, 20); + fullPower.Step(1); + fullPower.Construction.AssignRepairOrder( + UnitCommandStateView.ToRawEntityId(fullPowerBuilder), + UnitCommandStateView.ToRawEntityId(fullPowerDefense)); + fullPower.Step(51); + + Assert.That(fullPower.Entities.GetUnitRef(fullPowerDefense).CurrentHealth, Is.EqualTo(510)); + Assert.That(fullPower.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(911L), + "rate 5 and rate 10 telescope to the same S(Max)-S(6)=89 AE total"); + } + + [Test] + public void Repair_TwoReachableBuilders_HealAndDebitOnlyOnce() + { + var f = new Fixture(); + EntityId target = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True); + f.Entities.GetUnitRef(target).CurrentHealth = 100; + EntityId first = f.SpawnBuilder(0, 19, 20); + EntityId second = f.SpawnBuilder(0, 19, 21); + f.Step(1); + uint targetRaw = UnitCommandStateView.ToRawEntityId(target); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(first), targetRaw); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(second), targetRaw); + + f.Step(1); + + Assert.That(f.Entities.GetUnitRef(target).CurrentHealth, Is.EqualTo(110)); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(998L), + "one target receives one S(110)-S(100) debit despite two reachable Builders"); + } + + [Test] + public void Repair_OutOfReachFirstOrder_DoesNotBlockReachableSecond() + { + var f = new Fixture(); + EntityId target = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True); + f.Entities.GetUnitRef(target).CurrentHealth = 100; + EntityId far = f.SpawnBuilder(0, 60, 60); + EntityId near = f.SpawnBuilder(0, 19, 20); + f.Step(1); + uint targetRaw = UnitCommandStateView.ToRawEntityId(target); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(far), targetRaw); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(near), targetRaw); + + f.Step(1); + + Assert.That(f.Entities.GetUnitRef(target).CurrentHealth, Is.EqualTo(110)); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(998L)); + } + + [Test] + public void Repair_InsufficientWinnerClaimsTarget_AndOtherTargetsContinue() + { + var f = new Fixture(startingCredits: 2); + EntityId hq = f.Construction.PlaceCompletedBuilding(0, 3, 20, 20); + EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 40, 20); + f.Entities.GetUnitRef(hq).CurrentHealth = 100; + f.Entities.GetUnitRef(barracks).CurrentHealth = 100; + EntityId firstHqBuilder = f.SpawnBuilder(0, 19, 20); + EntityId secondHqBuilder = f.SpawnBuilder(0, 19, 21); + EntityId barracksBuilder = f.SpawnBuilder(0, 39, 20); + f.Step(1); + + uint hqRaw = UnitCommandStateView.ToRawEntityId(hq); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(firstHqBuilder), hqRaw); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(secondHqBuilder), hqRaw); + f.Construction.AssignRepairOrder( + UnitCommandStateView.ToRawEntityId(barracksBuilder), + UnitCommandStateView.ToRawEntityId(barracks)); + + f.Step(1); + + Assert.That(f.Entities.GetUnitRef(hq).CurrentHealth, Is.EqualTo(100), + "the first reachable HQ order claims before its 4 AE spend fails"); + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(110), + "a different target still processes in the same tick"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(0L)); + + Assert.That(f.Entities.DespawnUnit(firstHqBuilder), Is.True, "invalidate the previous winner"); + f.Economy.GetPlayerEconomy(0).AddCredits(4); + f.Step(1); + + Assert.That(f.Entities.GetUnitRef(hq).CurrentHealth, Is.EqualTo(110), + "the later same-target order stayed active and resumes after credits arrive"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(0L)); } [Test] @@ -727,7 +1039,7 @@ public void Repair_Validation_RejectsNonBuilder_AndUndamagedTarget() public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).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); @@ -745,6 +1057,7 @@ public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint() public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "influence anchor"); 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); @@ -780,7 +1093,7 @@ public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected() public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); f.SpawnBuilder(0, 19, 20); EntityId soldier = f.Entities.SpawnUnit( 0, new Transform2D(SimFixed.FromInt(50), SimFixed.FromInt(50)), SimFixed.FromInt(4), @@ -817,7 +1130,7 @@ public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation() public void ProgressSites_ReassignsNonBuilderAssignment_DefenseInDepth() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); EntityId builder = f.SpawnBuilder(0, 19, 20); f.Step(1); Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); diff --git a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs index 2a1ae16..56f6305 100644 --- a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs @@ -207,11 +207,11 @@ public void PlaceBuilding_ThroughSealedCommands_AppliesAndRejectsDeterministical EntityId builder = host.SpawnBaseFixture(0, 4, 4); host.StepTick(); // commit the start balance (30 provided / 20 required) - // Legal: Storage (def 6, 300 AE) at (20,20) — the start grid + // Legal: Storage (def 6, 300 AE) at (4,8) — the start grid // (30 provided, 20 required) powers its 5, not the Barracks' 15: // the Alliance must build a Power plant before its Barracks // (Buildings.md power figures). - host.Submit(new PlaceBuildingPayload(6, 20, 20)); + host.Submit(new PlaceBuildingPayload(6, 4, 8)); // Insufficient funds: HQ (def 3, 2500 AE) at (30,20). host.Submit(new PlaceBuildingPayload(3, 30, 20)); host.StepTick(); @@ -231,7 +231,7 @@ public void PlaceBuilding_ThroughSealedCommands_AppliesAndRejectsDeterministical // Prerequisite: the DefensePlatform (def 11, 400 AE) needs a // completed Power plant — cheap enough that the generic cost // check passes and the domain check decides. - host.Submit(new PlaceBuildingPayload(11, 30, 30)); + host.Submit(new PlaceBuildingPayload(11, 12, 12)); host.StepTick(); Assert.That(host.Kernel.LastTickResults[0].Code, Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet)); Assert.That(host.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(700L), @@ -239,6 +239,28 @@ public void PlaceBuilding_ThroughSealedCommands_AppliesAndRejectsDeterministical Assert.That(host.Entities.IsValid(builder), Is.True); } + [Test] + public void Repair_ThroughSealedCommand_ChargesBeforeHealing() + { + var host = ProdHost.Create(Seed); + EntityId builder = host.SpawnBaseFixture(0, 4, 4); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True, "full-power anchor"); + EntityId barracks = host.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + host.Entities.GetUnitRef(builder).Transform = new Transform2D(SimFixed.FromInt(19), SimFixed.FromInt(20)); + host.Entities.GetUnitRef(barracks).CurrentHealth = 100; + host.StepTick(); + + host.Submit(new RepairPayload( + new[] { UnitCommandStateView.ToRawEntityId(builder) }, + UnitCommandStateView.ToRawEntityId(barracks))); + host.StepTick(); + + Assert.That(host.Kernel.LastTickResults[0].Code, Is.EqualTo(CommandResultCode.Applied)); + Assert.That(host.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(110)); + Assert.That(host.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(998L), + "S(110)-S(100)=2 AE is debited in the same tick before 10 HP are applied"); + } + [Test] public void QueueUnit_ThroughSealedCommands_T2GatingAndProducerRules() { @@ -277,7 +299,7 @@ public void FullLoop_BuildBarracks_QueueInfantry_SpawnsAtFootprint_OrderedToRall // Barracks' 15 — the Alliance builds its Power plant first // (Buildings.md); placed completed here, the test is about the // build/queue/spawn loop, not the power rule. - Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True); host.StepTick(); // commit the start balance // The auto-assigned fixture builder walks nowhere in this test — @@ -333,8 +355,8 @@ public void TwoKernels_ScriptedConstructionAndProduction_400Ticks_IdenticalHashe var hostB = ProdHost.Create(Seed); EntityId builderA = hostA.SpawnBaseFixture(0, 4, 4); EntityId builderB = hostB.SpawnBaseFixture(0, 4, 4); - Assert.That(hostA.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); - Assert.That(hostB.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); + Assert.That(hostA.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True); + Assert.That(hostB.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True); hostA.StepTick(); // commit the start balance on both kernels hostB.StepTick(); @@ -412,7 +434,7 @@ public void Snapshot_RestoredHost_ContinuesConstructionAndProductionIdentically( { var hostA = ProdHost.Create(Seed); EntityId builder = hostA.SpawnBaseFixture(0, 4, 4); - Assert.That(hostA.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + Assert.That(hostA.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True, "Power first — the start grid cannot power the Barracks (Buildings.md)"); hostA.StepTick(); // commit the start balance hostA.Submit(new PlaceBuildingPayload(7, 20, 20)); @@ -462,7 +484,7 @@ public void Replay_ConstructionAndProductionIntents_PlaybackReproducesEndHash() // (and therefore the playback) starts with the builder already // in reach of the future site — replay only replays commands. host.Entities.GetUnitRef(builder).Transform = new Transform2D(SimFixed.FromInt(19), SimFixed.FromInt(20)); - Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True, "Power first — the start grid cannot power the Barracks (Buildings.md)"); host.StepTick(); // commit the start balance before recording @@ -520,7 +542,7 @@ public void SetRallyPoint_OffMapCommand_IsRejected_ProductionContinuesNormally() { var host = ProdHost.Create(Seed); host.SpawnBaseFixture(0, 4, 4); - Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True, "Power first — a low-power grid would double the production time under test"); host.StepTick(); // commit the start balance EntityId barracks = host.Construction.PlaceCompletedBuilding(0, 7, 20, 20); diff --git a/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs b/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs index 45502b7..6c07c75 100644 --- a/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs @@ -373,7 +373,7 @@ public void ConstructionSite_CountsAsBuilding_AndKeepsTheSideAlive() // Slot 0 gets a real construction site: power provider + builder // + credits are the placement prerequisites. - EntityId power = host.Construction.PlaceCompletedBuilding(0, DefPower, 40, 40); + EntityId power = host.Construction.PlaceCompletedBuilding(0, DefPower, 26, 20); Assert.That(power.IsValid, Is.True, "power provider"); EntityId builder = host.SpawnUnit(0, 19, 20, UnitRole.Builder); host.Step(1); diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index 669e01e..d1b3ac1 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -34,21 +34,24 @@ namespace Nova.Simulation.Construction /// (a refusal rejects /// the command with RejectedInsufficientResources before anything /// 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). + /// deterministic: unknown/foreign definition; footprint outside the + /// 128x128 grid or occupied cells; then D-104 terrain, influence, + /// building-clearance and Aetherium-field geometry (all + /// RejectedInvalidTarget); then missing prerequisite role, the power rule + /// and site capacity (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: /// PowerProvided - PowerRequired >= PowerRequired of the new /// 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 + /// startStatePerPlayer): placed through the command path it is gated by + /// funds, D-104 geometry and the power rule. The remaining /// prerequisite roles (VehicleFactory needs a Refinery, ResearchLab a /// Barracks, Radar and DefensePlatform a Power plant) are unchanged. - /// bypasses placement validation - /// entirely — it is the direct write the match start is placed with. + /// bypasses gameplay placement + /// validation entirely, including D-104 geometry — it is the direct write + /// deterministic match setup uses. /// /// /// Same-tick power stacking (documented, same precedent as the combat @@ -99,10 +102,11 @@ namespace Nova.Simulation.Construction /// (floor) and despawns it; a running production queue on a sold or /// destroyed building is lost without refund (production domain). /// Repair assigns a Builder a standing repair order on an own completed - /// damaged building: in reach (same Chebyshev rule) the target gains - /// HP per tick up to its MaxHealth, - /// where the order resolves; out of reach the order is HELD, never - /// dropped; Stop clears it. Repair is unaffected by low power. + /// damaged building. In reach (same Chebyshev rule) the target gains + /// HP per tick, halved under low power, + /// and pays an exact cumulative integer share of 30% of its new price. + /// At most one reachable Builder may repair a target per tick; out-of- + /// reach orders are HELD, never dropped, and Stop clears them. /// /// /// State (snapshot block , @@ -157,6 +161,22 @@ public sealed class ConstructionSystem : IStatefulSimSystem /// Provisional repair rate in HP per tick per repairing Builder (Q-040 candidate). public const int RepairRateHpPerTick = 10; + /// Full rebuild-equivalent repair price as a percentage of the building's new price (D-104). + public const int RepairCostPercent = 30; + + /// Maximum footprint-aware Chebyshev distance from an own construction anchor (D-104). + public const int BuildInfluenceRadiusCells = 8; + + /// Minimum footprint-aware Chebyshev distance between construction footprints (D-104). + public const int MinimumBuildingDistanceCells = 2; + + /// Allowed field-distance interval for a Refinery footprint (D-104). + public const int RefineryMinimumFieldDistanceCells = 1; + public const int RefineryMaximumFieldDistanceCells = 3; + + /// Minimum field distance for every non-Refinery building footprint (D-104). + public const int MinimumNonRefineryFieldDistanceCells = 2; + private struct SiteState { public bool IsActive; @@ -314,10 +334,11 @@ 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, - /// power rule and site capacity (RejectedPrerequisitesNotMet). Cost - /// is the executor's separate check (RejectedInsufficientResources) - /// and runs BEFORE this. + /// occupied cells; then terrain, build influence, one-cell building + /// ring and field spacing (RejectedInvalidTarget); then prerequisite + /// role, power rule and site capacity + /// (RejectedPrerequisitesNotMet). Cost is the executor's separate + /// check (RejectedInsufficientResources) and runs BEFORE this. /// public CommandResultCode ValidatePlacement(byte playerSlot, ushort buildingDefId, int originX, int originY) { @@ -337,6 +358,13 @@ public CommandResultCode ValidatePlacement(byte playerSlot, ushort buildingDefId { return CommandResultCode.RejectedInvalidTarget; } + if (!FootprintIsWalkable(originX, originY) + || !IsInsideBuildInfluence(playerSlot, originX, originY) + || !HasMinimumBuildingSpacing(originX, originY) + || !HasValidFieldSpacing(def.Role, originX, originY)) + { + return CommandResultCode.RejectedInvalidTarget; + } if (def.HasPrerequisite && !HasFinishedBuilding(playerSlot, def.PrerequisiteRole)) { return CommandResultCode.RejectedPrerequisitesNotMet; @@ -446,11 +474,10 @@ public bool TryPlaceBuilding(byte playerSlot, ushort buildingDefId, int originX, } /// - /// Match-setup placement of a COMPLETED building: bypasses cost, - /// prerequisite and power validation by contract (the only caller is - /// deterministic host content wiring — this is how the manifest's - /// starting HQ + Refinery and the start-refinery prerequisite - /// exception are honored). Applies the same completion effects as a + /// Match-setup placement of a COMPLETED building: bypasses cost and all + /// gameplay placement validation by contract, including D-104 terrain, + /// influence and spacing (callers are deterministic host content + /// wiring and explicit test setup). Applies the same completion effects as a /// finished site: building-role entity at full HP, footprint occupied, /// ResearchLab sets the T2 unlock. Returns EntityId.Invalid when the /// definition is unknown, the footprint is blocked or the placement @@ -778,6 +805,15 @@ private bool HasLivingHarvester(byte playerSlot) private void ProcessRepairOrders() { + Span claimedTargets = stackalloc uint[MaxRepairOrders]; + Span winningOrders = stackalloc byte[MaxRepairOrders]; + winningOrders.Clear(); + int claimedTargetCount = 0; + + // Select winners from the tick-start state before any target is + // healed. This preserves later same-target orders even when the + // winner reaches full health, while still clearing targets that + // were already full when the tick began. for (int i = 0; i < MaxRepairOrders; i++) { ref RepairOrderState order = ref _repairs[i]; @@ -785,7 +821,9 @@ private void ProcessRepairOrders() EntityId builderId = UnitCommandStateView.ToEntityId(order.BuilderRaw); EntityId targetId = UnitCommandStateView.ToEntityId(order.TargetRaw); - if (!_entityManager.IsValid(builderId) || !_entityManager.IsValid(targetId)) + if (!_entityManager.TryGetUnit(builderId, out UnitState builder) + || builder.Role != UnitRole.Builder + || !_entityManager.TryGetUnit(targetId, out UnitState target)) { order.IsActive = false; continue; @@ -798,24 +836,92 @@ private void ProcessRepairOrders() continue; } - ref UnitState target = ref _entityManager.GetUnitRef(targetId); + if (!SimDefinitions.TryGetBuilding( + _buildings[placementIndex].BuildingDefId, + out _)) + { + order.IsActive = false; + continue; + } + if (target.CurrentHealth >= target.MaxHealth) { - order.IsActive = false; // fully repaired: the order resolves + order.IsActive = false; // fully repaired at tick start: the order resolves continue; } - ref readonly UnitState builder = ref _entityManager.GetUnitRef(builderId); if (!IsInReachOfFootprint(in builder, _buildings[placementIndex].OriginX, _buildings[placementIndex].OriginY)) { continue; // held, not dropped } - int repaired = target.CurrentHealth + RepairRateHpPerTick; - target.CurrentHealth = repaired > target.MaxHealth ? target.MaxHealth : repaired; + // Once a damaged target has been claimed this tick, later + // reachable orders remain standing without paying or healing. + // The claim also survives an insufficient-credit refusal so + // order-table multiplicity can never multiply repair work. + bool alreadyClaimed = false; + for (int claimed = 0; claimed < claimedTargetCount; claimed++) + { + if (claimedTargets[claimed] == order.TargetRaw) + { + alreadyClaimed = true; + break; + } + } + if (alreadyClaimed) + { + continue; + } + + claimedTargets[claimedTargetCount++] = order.TargetRaw; + winningOrders[i] = 1; + } + + for (int i = 0; i < MaxRepairOrders; i++) + { + if (winningOrders[i] == 0) continue; + + ref RepairOrderState order = ref _repairs[i]; + EntityId targetId = UnitCommandStateView.ToEntityId(order.TargetRaw); + int placementIndex = IndexOfBuilding(order.TargetRaw); + ref UnitState target = ref _entityManager.GetUnitRef(targetId); + + SimDefinitions.TryGetBuilding( + _buildings[placementIndex].BuildingDefId, + out SimBuildingDefinition def); + + ref PlayerEconomyState repairEco = ref _economy.GetPlayerEconomy(target.PlayerId); + int rate = repairEco.IsLowPower + ? RepairRateHpPerTick / 2 + : RepairRateHpPerTick; + int healthBefore = Math.Max(0, target.CurrentHealth); + int healthAfter = Math.Min(target.MaxHealth, healthBefore + rate); + + long fullRepairCost = (long)def.CostAE * RepairCostPercent / 100; + long paidBefore = RepairCostAtHealth(fullRepairCost, healthBefore, target.MaxHealth); + long paidAfter = RepairCostAtHealth(fullRepairCost, healthAfter, target.MaxHealth); + long tickCost = paidAfter - paidBefore; + + if (tickCost > 0 && !repairEco.TrySpendCredits(tickCost)) + { + continue; // atomic refusal: no debit and no healing + } + + target.CurrentHealth = healthAfter; + if (healthAfter >= target.MaxHealth) + { + order.IsActive = false; + } } } + private static long RepairCostAtHealth(long fullRepairCost, int health, int maxHealth) + { + if (fullRepairCost <= 0 || health <= 0 || maxHealth <= 0) return 0; + if (health >= maxHealth) return fullRepairCost; + return fullRepairCost * health / maxHealth; + } + // ------------------------------------------------------------------ // Internals // ------------------------------------------------------------------ @@ -896,6 +1002,121 @@ private static bool FootprintInsideMap(int originX, int originY) return originX >= 0 && originY >= 0 && originX + f <= GridSize && originY + f <= GridSize; } + private bool FootprintIsWalkable(int originX, int originY) + { + if (_costField == null) return true; + + int f = SimDefinitions.BuildingFootprintCells; + for (int y = originY; y < originY + f; y++) + { + for (int x = originX; x < originX + f; x++) + { + if (!_costField.IsWalkable((ushort)x, (ushort)y)) return false; + } + } + return true; + } + + private bool IsInsideBuildInfluence(byte playerSlot, int originX, int originY) + { + for (int i = 0; i < MaxBuildings; i++) + { + ref readonly PlacementState placement = ref _buildings[i]; + if (!placement.IsActive) continue; + if (!SimDefinitions.TryGetBuilding(placement.BuildingDefId, out SimBuildingDefinition def)) continue; + if (def.Role != UnitRole.HQ && def.Role != UnitRole.Storage && def.Role != UnitRole.Power) continue; + + EntityId id = UnitCommandStateView.ToEntityId(placement.RawEntityId); + if (!_entityManager.TryGetUnit(id, out UnitState unit) || unit.PlayerId != playerSlot) continue; + if (FootprintDistance(originX, originY, placement.OriginX, placement.OriginY) <= BuildInfluenceRadiusCells) + { + return true; + } + } + return false; + } + + private bool HasMinimumBuildingSpacing(int originX, int originY) + { + for (int i = 0; i < MaxSites; i++) + { + ref readonly SiteState site = ref _sites[i]; + if (site.IsActive + && FootprintDistance(originX, originY, site.OriginX, site.OriginY) < MinimumBuildingDistanceCells) + { + return false; + } + } + + for (int i = 0; i < MaxBuildings; i++) + { + ref readonly PlacementState placement = ref _buildings[i]; + if (!placement.IsActive) continue; + EntityId id = UnitCommandStateView.ToEntityId(placement.RawEntityId); + if (!_entityManager.IsValid(id)) continue; + if (FootprintDistance(originX, originY, placement.OriginX, placement.OriginY) < MinimumBuildingDistanceCells) + { + return false; + } + } + return true; + } + + private bool HasValidFieldSpacing(UnitRole role, int originX, int originY) + { + bool refineryHasFieldInRange = false; + for (int i = 0; i < _economy.FieldCount; i++) + { + if (!_economy.TryGetFieldAtIndex(i, out AetheriumField field)) return false; + int distance = PointToFootprintDistance(field.GridPos.X, field.GridPos.Y, originX, originY); + if (distance == 0) return false; + + if (role == UnitRole.Refinery) + { + if (distance >= RefineryMinimumFieldDistanceCells + && distance <= RefineryMaximumFieldDistanceCells) + { + refineryHasFieldInRange = true; + } + } + else if (distance < MinimumNonRefineryFieldDistanceCells) + { + return false; + } + } + + return role != UnitRole.Refinery || refineryHasFieldInRange; + } + + private static int PointToFootprintDistance(int pointX, int pointY, int originX, int originY) + { + int f = SimDefinitions.BuildingFootprintCells; + return RectangleDistance( + pointX, pointY, pointX, pointY, + originX, originY, originX + f - 1, originY + f - 1); + } + + private static int FootprintDistance(int leftOriginX, int leftOriginY, int rightOriginX, int rightOriginY) + { + int f = SimDefinitions.BuildingFootprintCells; + return RectangleDistance( + leftOriginX, leftOriginY, leftOriginX + f - 1, leftOriginY + f - 1, + rightOriginX, rightOriginY, rightOriginX + f - 1, rightOriginY + f - 1); + } + + private static int RectangleDistance( + int leftMinX, int leftMinY, int leftMaxX, int leftMaxY, + int rightMinX, int rightMinY, int rightMaxX, int rightMaxY) + { + int dx = leftMaxX < rightMinX + ? rightMinX - leftMaxX + : rightMaxX < leftMinX ? leftMinX - rightMaxX : 0; + int dy = leftMaxY < rightMinY + ? rightMinY - leftMaxY + : rightMaxY < leftMinY ? leftMinY - rightMaxY : 0; + return Math.Max(dx, dy); + } + private bool FootprintFree(int originX, int originY) { int f = SimDefinitions.BuildingFootprintCells; diff --git a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs index 7f6359f..040460e 100644 --- a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs +++ b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs @@ -226,6 +226,24 @@ public bool TryGetField(ushort fieldId, out AetheriumField field) return false; } + /// + /// Read-only lookup by deterministic registration index. Unlike + /// , exhausted fields are included: + /// construction placement treats their cells as permanent map + /// features. Returns false for every index outside the registered + /// range and never exposes the mutable backing array. + /// + internal bool TryGetFieldAtIndex(int index, out AetheriumField field) + { + if (index >= 0 && index < _fieldCount) + { + field = _fields[index]; + return true; + } + field = default; + return false; + } + /// /// Nearest field with reserve left to a grid cell, false when every /// registered field is exhausted (or none is registered). Ascending diff --git a/CHANGELOG.md b/CHANGELOG.md index f149aaf..4c9e366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,11 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de gespulte Partie ohne dieses Etikett nichts wert ist. ### Geändert +- **16.9/C6: Bauplätze und Reparaturen kosten Raum und Aetherium (D-104).** + Neubauten folgen footprintbasierten Einfluss-, Feld-, Gelände- und + Gebäudeabständen; Reparaturen kosten kumulativ 30 % des Neupreises. Je Ziel + und Tick wirkt nur der erste valide und erreichbare Reparaturauftrag, und bei + fehlendem AE bleiben Guthaben und Trefferpunkte vollständig 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/ArmorSystem.md b/docs/gamedesign/ArmorSystem.md index 4c0ce17..7bde125 100644 --- a/docs/gamedesign/ArmorSystem.md +++ b/docs/gamedesign/ArmorSystem.md @@ -1,6 +1,6 @@ # Panzerungssystem (Armor System) -**Version:** 0.4.0 | **Status:** Entwurf – Matrix führend nach D-074, in Code implementiert | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 2 +**Version:** 0.5.0 | **Status:** Entwurf – Matrix führend nach D-074, Reparaturwert nach D-104 | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 16 ## Zweck @@ -99,7 +99,10 @@ Allianz und Legion heilen nicht passiv; Reparatur ist aktive, kostenpflichtige A | Infanterie | Nicht reparierbar; Allianz: Medic-Einheit heilt Infanterie (separates Heil-Profil, analog 3 %/s); Legion: **keine Infanterie-Heilung** (D-027.4 – Masse-Identität, Ausgleich über günstige Neuproduktion: Ersatz statt Erhalt) | | Evolvierte | Keine Reparatur-Mechanik (D-011); Bio-Heiler heilt nur Infanterie | -Kostenprinzip: Reparatur kostet einen Bruchteil des Neubaus (Faustregel: Volle Reparatur ≈ 50 % der Baukosten). Begründung: Erhalt soll sich lohnen, aber nicht gratis sein – das hält AE-Druck (D-010) auch im Verteidigungsspiel aufrecht. +Kostenprinzip: Reparatur kostet in MS-1 kumulativ **30 % der Baukosten** für +0→100 % HP (D-104). Begründung: Erhalt soll sich lohnen, aber nicht gratis sein +– das hält AE-Druck (D-010) auch im Verteidigungsspiel aufrecht. Die Höhe bleibt +als Q-047 nach der ersten gespielten Runde zu prüfen. ## Wechselwirkungen mit dem Schadenssystem @@ -126,3 +129,4 @@ Kostenprinzip: Reparatur kostet einen Bruchteil des Neubaus (Faustregel: Volle R | 0.2.0 | 2026-07-21 | Korrekturlauf Sprint 2 (D-020–D-030) | Lead Gameplay Designer | | 0.3.0 | 2026-07-21 | Feinschliff Sprint 2 Runde 2 (D-031) | Lead Gameplay Designer | | 0.4.0 | 2026-07-26 | Autoritätsvermerk nach D-074 ergänzt: die 6 × 6-Matrix ist alleinige kanonische Quelle und in `Nova.Simulation.Combat.DamageMatrix` implementiert; Lokaltabellen in Infantry.md/Vehicles.md aufgehoben. **Keiner der 36 Werte geändert** | Agent (unter Inhaber-Delegation, D-074) | +| 0.5.0 | 2026-08-09 | D-104: die widersprüchliche 50-%-Faustregel auf den implementierten kumulativen MS-1-Startwert von 30 % vereinheitlicht; Q-047 bleibt als Balancingfrage offen | Project Owner / Agent (unter Delegation) | diff --git a/docs/gamedesign/Economy.md b/docs/gamedesign/Economy.md index 8d985b4..0432a6e 100644 --- a/docs/gamedesign/Economy.md +++ b/docs/gamedesign/Economy.md @@ -1,6 +1,6 @@ # Wirtschaftssystem (Economy) -**Version:** 0.4.0 | **Status:** Entwurf (Korrekturlauf Sprint 4) | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 4 +**Version:** 0.5.0 | **Status:** Entwurf (Korrekturlauf Sprint 16) | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 16 ## Zweck @@ -100,8 +100,8 @@ Begründung: Mit 1.000 AE Start und ~600 AE/min ist Tier 1 sofort, Tier 2 nach ~ | Regel | Wert v0.1 | |---|---| -| Reparatur (Allianz/Legion, Gebäude) | Kosten 50 % der Baukosten für 0→100 % HP, anteilig; Rate ~3 % HP/s; nur bei positivem Energiesaldo in voller Rate, bei Low-Power halbiert | -| Reparatur (Fahrzeuge) | Über Repair-Drohne (D-014) oder Werft-Funktion der Fahrzeugfabrik, gleiche 50-%-Regel | +| Reparatur (Allianz/Legion, Gebäude) | **MS-1: kumulativ 30 %** der Baukosten für 0→100 % HP (D-104), anteilig über `S(h)`, ohne Rundungsdrift; Rate 10 HP/Tick, bei Low Power 5 HP/Tick | +| Reparatur (Fahrzeuge) | Über Repair-Drohne (D-014) oder Werft-Funktion der Fahrzeugfabrik; bis zur Umsetzung gilt derselbe 30-%-Startwert aus D-104 | | Evolvierte | Keine aktive Reparatur: Regeneration ~1 % HP/s kostenlos, doppelt so schnell auf/nahe **lebender** Aetherium-Felder (D-011; Einschränkung auf lebende Felder gemäß D-027); kein AE-Abzug – Ausgleich über langsamere Rate | | Verkauf | 50 % der investierten AE zurück (Basiswert, keine Reparatur-Rückerstattung); 5 s Abwickel-Phase, Gebäude in dieser Zeit verwundbar und funktionslos; Evolvierte "Rückbau" übernimmt dieselbe 50-%-Regel (Resorption) | @@ -156,3 +156,4 @@ Leitplanke: Gesamt-AE-Fluss pro Spieler über ein typisches 25-min-Match ≈ 25. | 0.2.0 | 2026-07-21 | Korrekturlauf Sprint 2 (D-020–D-030) | Lead Gameplay Designer | | 0.3.0 | 2026-07-21 | Korrekturlauf Sprint 4 (D-043–D-052, Review-Findings): Gebäudekosten/-energie durch Verweise auf Buildings.md ersetzt (Review F-03, D-047-Grundsatzregel); Economy.md behält nur Systemlogik (Raten, Low-Power, Lager) | Lead Gameplay Designer | | 0.4.0 | 2026-07-21 | F-03 vollständig geschlossen: Harvester-Kosten in der Fraktions-Wirtschaftsmodifier-Tabelle durch Verweis auf die führende Quelle [Vehicles.md](./Vehicles.md) ersetzt (D-047) – keine dritte Zahl mehr neben Vehicles.md (700/550/620 AE) | Lead Gameplay Designer | +| 0.5.0 | 2026-08-09 | D-104: Reparaturkosten für MS-1 auf den implementierten kumulativen Startwert von 30 % vereinheitlicht; ganzzahlige `S(h)`-Abrechnung und Low-Power-Rate präzisiert | Project Owner / Agent (unter Delegation) | diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md index 4c4e8b5..6812e97 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.36.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 16 ## Zweck @@ -2992,6 +2992,71 @@ 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-104 | verbindlich | Sprint 16.9 (footprintbasierte Platzierung und deterministische Reparaturkosten) + +**Status:** Die Zielwerte stammen aus dem Inhaberauftrag vom 2026-08-09; die +deterministische Ausformung wurde vom Agenten unter Delegation entschieden und +bleibt überstimmbar. D-102 und D-103 sind seriell für die vorhergehenden Pakete +16.7 und 16.8 reserviert. + +**Kontext:** Die bestehende Platzierungsprüfung kennt nur Kartengrenze und +belegte Zellen. Reparaturen erhöhen Trefferpunkte kostenlos; mehrere +Bauarbeiter können dasselbe Ziel im selben Tick mehrfach bearbeiten. Die GDDs +widersprechen sich außerdem bei 30 beziehungsweise 50 Prozent +Reparaturkosten. + +**Alternativen:** + +1. Status quo beibehalten — verworfen, weil weder Territorium noch Feld- und + Gebäudeabstände wirken und Reparatur den AE-Druck vollständig umgeht. +2. Abstände von Gebäudezentren messen und jeden Reparaturauftrag separat + abrechnen — verworfen, weil verschieden große Footprints unterschiedlich + behandelt, Rundungsfehler vervielfacht und mehrere Bauarbeiter dasselbe Ziel + überberechnen würden. +3. Einen Reparaturkosten-Akkumulator im Zustand speichern — verworfen, weil + dafür ein neues Zustandsfeld und ein `StateVersion`-Bump nötig wären. +4. **Gewählt:** footprintbasierte Chebyshev-Abstände, zustandslose kumulative + Reparaturkosten und höchstens ein wirksamer Reparaturauftrag je Ziel und + Tick. + +**Entscheidung:** + +1. Abstände werden als kleinster Chebyshev-Abstand zwischen den beteiligten + Footprints gemessen. +2. Ein Neubau braucht ein eigenes, lebendes und fertiggestelltes HQ, Lager oder + Kraftwerk in höchstens acht Zellen Abstand. +3. Feldüberlappung ist immer verboten. Raffinerien brauchen zu mindestens + einem registrierten Aetheriumfeld Abstand 1 bis 3; alle anderen Gebäude zu + jedem Feld mindestens Abstand 2. Erschöpfte Felder bleiben Kartenmerkmale. +4. Zu aktiven Baustellen und lebenden fertiggestellten Gebäuden gilt mindestens + Abstand 2 — ein vollständig leerer Zellenring. +5. Jede Footprintzelle muss über `CostField.IsWalkable` begehbar sein; + `Pathfinding/` bleibt unverändert. +6. Sei `R = floor(CostAE × 30 / 100)` und + `S(h) = floor(R × clamp(h, 0, MaxHealth) / MaxHealth)`. Eine Reparatur von + `h0` auf `h1` kostet exakt `S(h1) − S(h0)`. So kostet die vollständige + Lebensleiste kumulativ 30 Prozent des Neupreises, ohne zusätzlichen Zustand + und ohne Rundungsdrift. +7. Reicht das AE nicht, ändern sich weder AE noch Trefferpunkte. Pro Ziel und + Tick gewinnt der erste deterministisch geordnete, valide, beschädigte und + in Reichweite befindliche Reparaturauftrag; weitere Aufträge auf dasselbe + Ziel wirken nicht. Der Gewinner behält den Anspruch auch bei fehlendem AE, + andere Ziele werden weiterbearbeitet. + +**Begründung:** Chebyshev entspricht dem quadratischen Grid und behandelt alle +Footprintgrößen gleich. Die kumulative Kostenfunktion bewahrt den Gesamtpreis +trotz ganzzahliger Tick-Abrechnung. Die Ein-Auftrag-Regel verhindert +Mehrfachheilung und Mehrfachkosten ohne neues Zustandsformat. + +**Konsequenzen:** Platzierung und Reparatur verändern deterministisches +Simulationsverhalten, aber weder Befehls- noch Zustandsformat. Die +Setup-Funktion `PlaceCompletedBuilding` bleibt ein ausdrücklicher Bypass. +Geschützte Golden-Baselines bleiben aus diesem PR heraus. Q-047 bleibt als +Balancingfrage offen: 30 Prozent sind der verbindlich implementierte +MS-1-Startwert, noch kein gespielter Endwert. + ## 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 +3137,7 @@ Wer den Harness umbenennt, sagt es an. Keine Baseline-Datei ist berührt. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.36.0 | 2026-08-09 | D-104 aufgenommen: footprintbasierte Chebyshev-Platzierung mit Einfluss-, Feld-, Gelände- und Gebäudeabständen sowie zustandslose kumulative Reparaturkosten von 30 Prozent; Mehrfachreparatur je Ziel und Tick deterministisch ausgeschlossen | Project Owner / 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/OpenQuestions.md b/docs/production/OpenQuestions.md index 6394d23..950a8e9 100644 --- a/docs/production/OpenQuestions.md +++ b/docs/production/OpenQuestions.md @@ -1,6 +1,6 @@ # Open Questions -**Version:** 1.12.1 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Executive Producer | **Sprint:** laufend (Stand 16–18) +**Version:** 1.12.2 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Executive Producer | **Sprint:** laufend (Stand 16–18) > **Zum Stand dieses Dokuments.** Alles bis Q-040 — die Einträge selbst wie die > Abschnitte „Offene Punkte" und „Nächste Schritte" — argumentiert mit der @@ -35,7 +35,7 @@ Zentrales Register aller offenen Fragen mit Owner-Sprint und Priorität. Eine Fr | Q-044 | P2 | Halte-Feuer für ausgewählte Einheiten: „Stoppen" löscht seit D-097 zusätzlich den Angriffsbefehl (`UnitState.AttackTarget`), aber die Auto-Zielerfassung (D-087) erfasst im nächsten Tick neu. Ein echtes „Feuer einstellen" braucht einen Haltezustand in `Simulation/Combat/` und damit den externen Einheitenstrang. | D-097 | Sprint 13B | offen – liegt beim Einheitenstrang, als Befund zu übergeben | | Q-045 | P3 | Health-Endpunkt für den Relay: [Sprint 15](hashkrieg/15_Sprint_Netzstabilitaet.md) Paket 15.5 sieht einen vor. Er wäre ein zweiter Listener und damit eine zusätzliche Firewallregel auf einem Dienst, dessen einzige Betriebsgrenze heute die enge Quelladress-Firewall ist. | Codebefund 2026-08-09 | Sprint 15 | offen – zurückgestellt, Nutzen gegen zweiten Listener abzuwägen | | Q-046 | P2 | Aufbewahrung der Relay-Aufzeichnungen: `.novarec`-Dateien sind der einzige Desync-Nachweis (D-089). Es gibt heute weder eine Aufbewahrungsregel noch ein Aufräumen; [Sprint 15](hashkrieg/15_Sprint_Netzstabilitaet.md) Paket 15.5 nennt ein Aufräumen, aber keine Frist. Wie lange werden sie gehalten, und ab welcher Belegung wird gelöscht? Block 4 des [Großauftrags](hashkrieg/AUFTRAG_Grossblock.md) beauftragt dazu einen **Vorschlag** für die Regel im Runbook (naheliegender Startwert 30 Tage analog 17.5); die Frist selbst entscheidet der Inhaber. | Codebefund 2026-08-09 | Sprint 15 | offen – Vorschlag kommt aus Block 4, Frist und Schwelle entscheidet der Inhaber | -| Q-047 | P2 | Reparaturkosten-Höhe: 30 % des Neupreises ist ein Startwert aus Strang C, kein gemessener Wert. Zu hoch macht Verteidigung unbezahlbar, zu niedrig macht die Kosten wirkungslos. 16.9 steht auf der ersten Position der Abwurfliste von Sprint 16 — entfällt das Paket, verschiebt sich diese Frage mit ihm. | [Sprint 16](hashkrieg/16_Sprint_Wirtschaft.md), Paket 16.9 | Sprint 16 | offen – Startwert, die erste gespielte Runde mit 16.9 prüft ihn | +| Q-047 | P2 | Reparaturkosten-Höhe: D-104 implementiert 30 % des Neupreises als MS-1-Startwert, nicht als gemessenen Endwert. Zu hoch macht Verteidigung unbezahlbar, zu niedrig macht die Kosten wirkungslos. | [Sprint 16](hashkrieg/16_Sprint_Wirtschaft.md), Paket 16.9 / D-104 | Sprint 16 | offen – 30 % implementiert; die erste gespielte Runde mit 16.9 prüft die Höhe | | Q-048 | P3 | Reparaturzone an Fahrzeugfabrik und Kaserne ([#55](https://github.com/VibecodingGermany/Project_Nova/issues/55)) und Sanitäter ([#56](https://github.com/VibecodingGermany/Project_Nova/issues/56)): beide aus dem ersten Betatest, beide bewusst nicht in Sprint 16 — #55 wartet auf eine gespielte Runde mit 16.9, #56 ist nur Doku und keine neue Einheit. 16.9 steht auf der ersten Position der Abwurfliste von Sprint 16 — entfällt das Paket, verschiebt sich die Frage nach #55 mit ihm. | T-01, Betatest 2026-08-09 | offen | offen – ohne Owner-Sprint | ## Geschlossene Fragen @@ -126,3 +126,4 @@ Zentrales Register aller offenen Fragen mit Owner-Sprint und Priorität. Eine Fr | 1.11.6 | 2026-07-26 | Q-040 um (k) erweitert: Construction/Production-Timing-Provisorien — Same-Tick-Power-Stacking (committed Vortick-Balance, kollektive Überziehung, Kandidat Placement-Limit) und Footprint-Sweep-Timing (Freigabe erst im Folgetick) | Executive Producer | | 1.12.0 | 2026-08-09 | Q-041 bis Q-048 aus den Vertagungen des Inhabers vom 2026-08-09 eröffnet: rohe IPs im Zugriffsprotokoll, Datenschutzerklärung und Widerspruchsschalter, Zeitpunkt des UI-Umstiegs, Halte-Feuer, Health-Endpunkt und Aufbewahrung der Relay-Aufzeichnungen, Höhe der Reparaturkosten, Reparaturzone und Sanitäter. Hinweiskasten ergänzt, der die Einträge bis Q-040 als historisch aus dem mit D-076 abgeschafften Gate-Regime kennzeichnet; Sprintstempel im Kopf von „7" auf den laufenden Stand berichtigt | Executive Producer | | 1.12.1 | 2026-08-09 | Q-046 um den in Block 4 des Großauftrags beauftragten Vorschlag für die Aufbewahrungsregel ergänzt (Startwert 30 Tage analog 17.5, Frist bleibt Inhaberentscheidung); Q-047 und Q-048 an die Abwurfliste von Sprint 16 gebunden (16.9 erste Abwurfposition); Hinweiskasten um den zulässigen Fall „ohne Owner-Sprint" erweitert | Executive Producer | +| 1.12.2 | 2026-08-09 | Q-047 an D-104 gebunden: 30 % sind als kumulativer MS-1-Startwert implementiert; die Frage bleibt bis zur ersten gespielten 16.9-Runde als Balancingprüfung offen | Project Owner / Agent (unter Delegation) | diff --git a/docs/production/hashkrieg/16_Sprint_Wirtschaft.md b/docs/production/hashkrieg/16_Sprint_Wirtschaft.md index 8b02f2d..4410968 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.3.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 @@ -231,15 +231,27 @@ nennt für sechs von neun Rollen Mehrfachvoraussetzungen. Eine Bitmaske über ### 16.9 · Platzierungsregeln und Reparaturkosten (C6) -- **Platzierung:** Bau-Einflussradius 8 Zellen um HQ / Lager / Kraftwerk, - Mindestabstand zu Aetherium-Feldern, Gebäudeabstand. Heute prüft der Code nur - „innerhalb der Karte" und „Zelle frei". Die Begehbarkeitsprüfung liest - `Pathfinding.CostField` — **Vertragsfläche, `IsWalkable` wird benutzt, nicht - geändert.** -- **Reparatur kostet 30 % des Neupreises.** Zwei Details, die dazugehören: - - `ProcessRepairOrders` hat **keine Ziel-Deduplikation** — mehrere Bauarbeiter - am selben Gebäude zahlen im selben Tick mehrfach. Das ist zu lösen, sonst - ist es der Betatest-Fehler der nächsten Runde. +- **Platzierung (D-104):** Abstände werden footprintbasiert in der + Chebyshev-Metrik gemessen. Jede der neun Footprintzellen muss über + `CostField.IsWalkable` begehbar sein. Ein Neubau braucht in höchstens acht + Zellen Entfernung ein eigenes, lebendes, fertiges HQ, Lager oder Kraftwerk + und zu jeder aktiven Baustelle beziehungsweise jedem lebenden fertigen + Gebäude mindestens Abstand 2. Feldüberlappung ist immer verboten; + Raffinerien brauchen zu mindestens einem registrierten Feld Abstand 1 bis 3, + alle anderen Gebäude zu jedem Feld mindestens Abstand 2. Erschöpfte Felder + zählen weiter. `Pathfinding.CostField` bleibt Vertragsfläche: + **`IsWalkable` wird benutzt, nicht geändert.** +- **Reparatur kostet kumulativ 30 % des Neupreises (D-104).** Für + `R = floor(CostAE × 30 / 100)` und + `S(h) = floor(R × clamp(h, 0, MaxHealth) / MaxHealth)` kostet ein Tick + `S(h1) − S(h0)`. Das teleskopiert ohne Rundungsdrift und ohne neues + Zustandsfeld. Reicht AE nicht, ändern sich Guthaben und Trefferpunkte nicht. + Pro Ziel und Tick gewinnt der erste valide, beschädigte und erreichbare + Auftrag; spätere Aufträge wirken nicht, der Gewinner claimt auch bei + fehlendem AE, und andere Ziele laufen weiter. + Zwei bestehende Details gehören dazu: + - `ProcessRepairOrders` hatte **keine Ziel-Deduplikation** — mehrere + Bauarbeiter heilten dasselbe Gebäude im selben Tick mehrfach. - Die Bauphase läuft **nach** `RecomputePower` im selben Tick. Ein Abzug in der Reparaturschleife wirkt darum erst im Folgetick auf die Strombilanz. Das ist hinnehmbar; die Tickreihenfolge zu drehen wäre `SimulationKernel.cs` und @@ -329,8 +341,11 @@ 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-104 | Footprintbasierte Chebyshev-Platzierung; Reparatur kostet kumulativ 30 % des Neupreises, höchstens ein wirksamer Auftrag je Ziel und Tick | Inhaber (Zielwerte) / Agent (Ausformung) | -D-096 und D-097 sind im [DecisionLog](../DecisionLog.md) eingetragen. D-098 +D-096, D-097 und D-104 sind im [DecisionLog](../DecisionLog.md) eingetragen; +D-102 und D-103 bleiben seriell für die vorhergehenden Pakete 16.7 und 16.8 +reserviert. 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 @@ -354,4 +369,5 @@ Die Baseline-Neusetzung ist Zweck der Tests, kein Bruch. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.3.0 | 2026-08-09 | Paket 16.9 mit D-104 konkretisiert: footprintbasierte Einfluss-, Feld-, Gelände- und Gebäudeabstände sowie zustandslose kumulative 30-Prozent-Reparaturkosten und Ziel-Deduplikation festgeschrieben | Project Owner / Agent (unter Delegation) | | 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 | diff --git a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs index 7908432..1f093a7 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs @@ -48,12 +48,12 @@ public sealed class CanonicalAiOutcomeTests /// /// End-state hash of the canonical AI match, last moved by: Sprint 16 - /// package 16.2 (#46) — produced units spawn at the building footprint - /// and walk to their rally point. The AI itself is unchanged: - /// AiBehaviorId stayed r5.779A1B5B. - /// Previous value: 0x8C0B54F31F2986B7 (Sprint 16.1). + /// package 16.9 (D-104) — footprint-aware building and field spacing + /// moves the first legal AI construction footprint. The decision tick + /// is unchanged and the AI itself is unchanged: AiBehaviorId stayed + /// r6.E34435F9. Previous value: 0x9F93097AD526B6F7 (Sprint 16.2). /// - private const string PinnedEndState = "0x9F93097AD526B6F7"; + private const string PinnedEndState = "0x03EF340B5BF18195"; [Test] public void CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState() diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index e1c380a..534bfe4 100644 --- a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs @@ -28,20 +28,29 @@ private sealed class Fixture { public EntityManager Entities { get; } public EconomySystem Economy { get; } + public CostField CostField { get; } public ConstructionSystem Construction { get; } public SimulationKernel Kernel { get; } - public Fixture(long startingCredits = 1000, System.Action configure = null) + public Fixture( + long startingCredits = 1000, + System.Action configure = null, + bool addDefaultField = true) { Entities = new EntityManager(64); Economy = new EconomySystem(Entities, startingCredits); - Construction = new ConstructionSystem(Entities, Economy); + CostField = new CostField(ConstructionSystem.GridSize, ConstructionSystem.GridSize); + Construction = new ConstructionSystem(Entities, Economy, CostField); Kernel = new SimulationKernel(new SimRandom(42UL)); Kernel.RegisterSystem(Economy); Kernel.RegisterSystem(Construction); // Pre-start configuration hook (e.g. slot factions): the // SetSlotFaction guard locks the assignment at Kernel.Start(). configure?.Invoke(Economy); + if (addDefaultField && Economy.FieldCount == 0) + { + Economy.TryAddField(63, new GridPos2D(20, 24), 9000); + } Kernel.Start(); } @@ -199,7 +208,7 @@ 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, 22, 40, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(1, 22, 26, 20).IsValid, Is.True, "Legion power provider so the power rule does not mask the faction check"); f.Step(1); // commit the balance f.SpawnBuilder(0, 19, 20); @@ -218,7 +227,7 @@ 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, 22, 40, 40).IsValid, Is.True, "Legion power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(1, 22, 26, 20).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), @@ -255,7 +264,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, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); f.SpawnBuilder(0, 19, 20); f.Step(1); // commit the balance (phase-2 recompute) @@ -283,6 +292,7 @@ public void PlaceBuilding_ChargesExactCost_AndCreatesSiteEntity() public void PlaceBuilding_InsufficientFunds_FailsAndMutatesNothing() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "influence anchor"); f.SpawnBuilder(0, 19, 20); Assert.That(f.Construction.TryPlaceBuilding(0, 3, 20, 20), Is.False, "HQ costs 2500 (Buildings.md), balance is 1000"); @@ -309,15 +319,149 @@ public void PlaceBuilding_OccupiedOrOutOfMap_IsRejectedInvalidTarget() "an unknown definition id is an invalid target, not a cost failure"); } + [Test] + public void ValidatePlacement_RequiresEveryFootprintCellToBeWalkable() + { + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 12, 20).IsValid, Is.True, "influence anchor"); + + f.CostField.SetCost(22, 22, 254); + Assert.That(f.Construction.ValidatePlacement(0, 5, 20, 20), Is.EqualTo(CommandResultCode.Applied), + "rough terrain costs 1 through 254 stay walkable"); + + f.CostField.SetCost(22, 22, CostField.ImpassableCost); + Assert.That(f.Construction.ValidatePlacement(0, 5, 20, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "one impassable cell rejects the whole 3x3 footprint"); + } + + [Test] + public void ValidatePlacement_InfluenceUsesOwnLivingCompletedFootprints_AtDistanceEight() + { + var own = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + Assert.That(own.Construction.PlaceCompletedBuilding(0, 5, 10, 10).IsValid, Is.True); + Assert.That(own.Construction.ValidatePlacement(0, 5, 20, 10), Is.EqualTo(CommandResultCode.Applied), + "footprint distance 8 is inside influence"); + Assert.That(own.Construction.ValidatePlacement(0, 5, 21, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "footprint distance 9 is outside influence"); + + var enemy = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + Assert.That(enemy.Construction.PlaceCompletedBuilding(1, 5, 10, 10).IsValid, Is.True); + Assert.That(enemy.Construction.ValidatePlacement(0, 5, 20, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "an enemy completed anchor never supplies influence"); + + var dead = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + EntityId deadAnchor = dead.Construction.PlaceCompletedBuilding(0, 5, 10, 10); + Assert.That(dead.Entities.DespawnUnit(deadAnchor), Is.True); + Assert.That(dead.Construction.ValidatePlacement(0, 5, 20, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "a dead completed-table entry is not a living anchor"); + + var siteOnly = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + EntityId originalAnchor = siteOnly.Construction.PlaceCompletedBuilding(0, 3, 0, 10); + Assert.That(siteOnly.Construction.TryPlaceBuilding(0, 5, 10, 10), Is.True, "create an active Power site"); + Assert.That(siteOnly.Entities.DespawnUnit(originalAnchor), Is.True, "remove the only completed anchor"); + Assert.That(siteOnly.Construction.ValidatePlacement(0, 5, 20, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "an active site supplies spacing, never construction influence"); + } + + [Test] + public void ValidatePlacement_RequiresOneEmptyRingAroundBuildingsAndSites() + { + var buildings = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + Assert.That(buildings.Construction.PlaceCompletedBuilding(0, 5, 10, 10).IsValid, Is.True); + Assert.That(buildings.Construction.ValidatePlacement(0, 5, 13, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "edge-adjacent footprints have distance 1"); + Assert.That(buildings.Construction.ValidatePlacement(0, 5, 13, 13), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "diagonally adjacent footprints also have distance 1"); + Assert.That(buildings.Construction.ValidatePlacement(0, 5, 14, 10), Is.EqualTo(CommandResultCode.Applied), + "one empty cardinal ring gives distance 2"); + Assert.That(buildings.Construction.ValidatePlacement(0, 5, 14, 14), Is.EqualTo(CommandResultCode.Applied), + "one empty diagonal ring gives distance 2"); + + var sites = new Fixture(configure: e => e.TryAddField(1, new GridPos2D(60, 60), 9000)); + Assert.That(sites.Construction.PlaceCompletedBuilding(0, 5, 10, 10).IsValid, Is.True); + Assert.That(sites.Construction.TryPlaceBuilding(0, 5, 14, 10), Is.True, "site at legal distance 2"); + Assert.That(sites.Construction.ValidatePlacement(0, 5, 17, 10), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "an active site enforces the same empty ring"); + Assert.That(sites.Construction.ValidatePlacement(0, 5, 18, 10), Is.EqualTo(CommandResultCode.Applied)); + } + + [Test] + public void ValidatePlacement_EnforcesRoleSpecificFieldDistances() + { + var distanceZero = PlacementFixtureWithField(20, 20); + Assert.That(distanceZero.Construction.ValidatePlacement(0, 4, 18, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget)); + + var distanceOne = PlacementFixtureWithField(20, 20); + Assert.That(distanceOne.Construction.ValidatePlacement(0, 4, 17, 20), Is.EqualTo(CommandResultCode.Applied), + "Refinery distance 1 is legal"); + Assert.That(distanceOne.Construction.ValidatePlacement(0, 5, 17, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "every other role rejects field distance 1"); + + var distanceTwo = PlacementFixtureWithField(20, 20); + Assert.That(distanceTwo.Construction.ValidatePlacement(0, 5, 16, 20), Is.EqualTo(CommandResultCode.Applied), + "every non-Refinery accepts field distance 2"); + + var distanceThree = PlacementFixtureWithField(20, 20); + Assert.That(distanceThree.Construction.ValidatePlacement(0, 4, 15, 20), Is.EqualTo(CommandResultCode.Applied), + "Refinery distance 3 is legal"); + + var distanceFour = PlacementFixtureWithField(20, 20); + Assert.That(distanceFour.Construction.ValidatePlacement(0, 4, 14, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "Refinery distance 4 is outside its required field band"); + + var noField = new Fixture(addDefaultField: false); + Assert.That(noField.Construction.PlaceCompletedBuilding(0, 5, 8, 20).IsValid, Is.True); + Assert.That(noField.Construction.ValidatePlacement(0, 4, 16, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "a Refinery needs at least one registered field"); + } + + [Test] + public void ValidatePlacement_ExhaustedFieldsRemainPermanentSpacingFeatures() + { + var f = PlacementFixtureWithField(20, 20, reserveAE: 1); + EntityId harvester = f.Entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(20), SimFixed.FromInt(20)), + SimFixed.FromInt(2), + role: UnitRole.Harvester); + f.Entities.GetUnitRef(harvester).HarvestFieldId = 1; + f.Step(1); + Assert.That(f.Economy.TryGetField(1, out AetheriumField field) && field.IsExhausted, Is.True); + Assert.That(f.Construction.ValidatePlacement(0, 5, 17, 20), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), + "exhaustion changes reserve, not the field's permanent map cell"); + } + + [Test] + public void PlaceCompletedBuilding_BypassesGameplayPlacementGeometry() + { + var f = new Fixture(addDefaultField: false); + f.CostField.SetCost(20, 20, CostField.ImpassableCost); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 20, 20).IsValid, Is.True, + "deterministic setup bypasses terrain, influence and field-distance validation"); + } + + private static Fixture PlacementFixtureWithField(int fieldX, int fieldY, long reserveAE = 9000) + { + var f = new Fixture( + configure: economy => Assert.That( + economy.TryAddField(1, new GridPos2D(fieldX, fieldY), reserveAE), + Is.True), + addDefaultField: false); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 8, 20).IsValid, Is.True, "influence anchor"); + f.Step(1); + return f; + } + [Test] public void PlaceBuilding_MissingPrerequisite_IsRejectedPrerequisitesNotMet() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 26, 20).IsValid, Is.True, "non-Power influence anchor"); 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"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 30, 20).IsValid, Is.True); f.Step(1); // commit the balance (100 provided) Assert.That(f.Construction.ValidatePlacement(0, 11, 20, 20), Is.EqualTo(CommandResultCode.Applied)); } @@ -328,7 +472,7 @@ public void PlaceBuilding_PowerRule_RequiresSufficientFreePower() var f = new Fixture(); f.SpawnBuilder(0, 19, 20); // Committed balance: HQ 30 provided, Refinery 20 required -> 10 free. - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True); Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 44, 40).IsValid, Is.True); f.Step(1); // let the economy recompute the balance @@ -336,7 +480,7 @@ public void PlaceBuilding_PowerRule_RequiresSufficientFreePower() "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.ValidatePlacement(0, 5, 60, 60), Is.EqualTo(CommandResultCode.Applied), + Assert.That(f.Construction.ValidatePlacement(0, 5, 20, 20), Is.EqualTo(CommandResultCode.Applied), "power-providing buildings are exempt from the rule"); } @@ -348,7 +492,7 @@ public void RefineryPlacement_NeedsNoPowerPlant_TheCommandPathEnforcesOnlyThePow // factions. With a completed HQ (30 provided, covering the 20 // draw) the command path accepts it directly — the classic loop // start needs no Power plant first. - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ provides 30"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "HQ provides 30"); f.Step(1); // commit the balance Assert.That(f.Construction.ValidatePlacement(0, 4, 20, 20), Is.EqualTo(CommandResultCode.Applied), "no Power plant required (D-077)"); @@ -359,14 +503,14 @@ public void RefineryPlacement_NeedsNoPowerPlant_TheCommandPathEnforcesOnlyThePow f.Step(1); // commit 30 provided / 20 required // ... while the command path keeps enforcing it: the 10 free // power cannot cover a third Refinery's 20. - Assert.That(f.Construction.ValidatePlacement(0, 4, 30, 30), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet)); + Assert.That(f.Construction.ValidatePlacement(0, 4, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet)); } [Test] public void SiteProgress_RequiresBuilderInReach_PausesWhenAway() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); EntityId builder = f.SpawnBuilder(0, 60, 60); // far away f.Step(1); // commit the balance Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); @@ -393,6 +537,8 @@ public void SiteProgress_LowPower_ExactlyHalvesProgress() var f = new Fixture(); // Low power: a completed Refinery draws 20 with nothing provided. Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 6, 26, 20).IsValid, Is.True, + "Storage supplies influence without adding power"); 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 +559,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(3)); } [Test] public void Completion_BecomesRoleEntity_PowerAppliesFromNextTick() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "HQ influence and 30 power"); f.SpawnBuilder(0, 19, 20); Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True); uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); @@ -430,10 +577,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"); } @@ -441,7 +588,7 @@ public void Completion_BecomesRoleEntity_PowerAppliesFromNextTick() 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, 5, 26, 20).IsValid, Is.True); // power Assert.That(f.Construction.PlaceCompletedBuilding(0, 7, 44, 40).IsValid, Is.True); // barracks prerequisite f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -481,7 +628,7 @@ public void RefineryCompletion_GrantsTheFirstHarvesterFree() // down below 700 before the Refinery finishes can never earn // again — no Harvester, no Aetherium, no money for a Harvester. var f = new Fixture(startingCredits: 1000); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "HQ provides the 30 power the Refinery draws from"); f.SpawnBuilder(0, 19, 20); f.Step(1); // commit the balance @@ -511,10 +658,10 @@ public void RefineryCompletion_GrantedHarvester_StartsWithNearestFieldOrder() // the Refinery's footprint centre, ties resolved by index. var f = new Fixture(startingCredits: 1000, configure: eco => { - Assert.That(eco.TryAddField(1, new GridPos2D(30, 30), 9000), Is.True); + Assert.That(eco.TryAddField(1, new GridPos2D(20, 24), 9000), Is.True); Assert.That(eco.TryAddField(2, new GridPos2D(60, 60), 9000), Is.True); }); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ power"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "HQ power"); f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -523,7 +670,7 @@ public void RefineryCompletion_GrantedHarvester_StartsWithNearestFieldOrder() Assert.That(TryFindHarvester(f, 0, out UnitState harvester), Is.True, "the grant happened"); Assert.That(harvester.HarvestFieldId, Is.EqualTo(1), - "field 1 at (30,30) is closer to the footprint centre (21,21) than field 2 at (60,60)"); + "field 1 at (20,24) is closer to the footprint centre (21,21) than field 2 at (60,60)"); f.Step(50); Assert.That(TryFindHarvester(f, 0, out harvester), Is.True); @@ -534,17 +681,28 @@ public void RefineryCompletion_GrantedHarvester_StartsWithNearestFieldOrder() [Test] public void RefineryCompletion_WithoutFields_GrantedHarvesterCarriesNoOrder() { - var f = new Fixture(startingCredits: 1000); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); + var f = new Fixture(startingCredits: 1000, configure: eco => + { + Assert.That(eco.TryAddField(1, new GridPos2D(20, 24), 1), Is.True); + }); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True); f.SpawnBuilder(0, 19, 20); + EntityId temporaryHarvester = f.Entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(20), SimFixed.FromInt(24)), + SimFixed.FromInt(2), + role: UnitRole.Harvester); + f.Entities.GetUnitRef(temporaryHarvester).HarvestFieldId = 1; f.Step(1); + Assert.That(f.Economy.TryGetField(1, out AetheriumField field) && field.IsExhausted, Is.True); + Assert.That(f.Entities.DespawnUnit(temporaryHarvester), Is.True); Assert.That(f.Construction.TryPlaceBuilding(0, 4, 20, 20), Is.True); f.Step(250); Assert.That(TryFindHarvester(f, 0, out UnitState harvester), Is.True); Assert.That(harvester.HarvestFieldId, Is.EqualTo(0), - "no field registered: the grant still happens, only the order is skipped"); + "only exhausted fields remain: the grant still happens, only the order is skipped"); } [Test] @@ -553,9 +711,13 @@ public void RefineryCompletion_SecondRefinery_GrantsNothingWhileAHarvesterLives( // #43 latch: the grant is derived from the unit store — a second // Refinery (or a rebuild) grants nothing while any own Harvester // lives. Before 16.1 EVERY completed Refinery handed one out. - var f = new Fixture(startingCredits: 3000); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 44, 40).IsValid, Is.True, + var f = new Fixture(startingCredits: 3000, configure: economy => + { + Assert.That(economy.TryAddField(1, new GridPos2D(20, 24), 9000), Is.True); + Assert.That(economy.TryAddField(2, new GridPos2D(29, 24), 9000), Is.True); + }); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "HQ"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 30, 20).IsValid, Is.True, "power plant: two Refineries overdraw the HQ's 30 alone"); EntityId builderOne = f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -581,9 +743,13 @@ public void RefineryCompletion_AfterLosingEveryHarvester_TheGrantReArms() // The latch is the dead-end insurance, not a once-per-match // counter: with every Harvester lost the next completed Refinery // grants again. - var f = new Fixture(startingCredits: 3000); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 44, 40).IsValid, Is.True); + var f = new Fixture(startingCredits: 3000, configure: economy => + { + Assert.That(economy.TryAddField(1, new GridPos2D(20, 24), 9000), Is.True); + Assert.That(economy.TryAddField(2, new GridPos2D(29, 24), 9000), Is.True); + }); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 30, 20).IsValid, Is.True); EntityId builderOne = f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -634,7 +800,7 @@ public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged( public void CancelConstruction_Refunds75Percent_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).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 @@ -657,7 +823,7 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint() public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); @@ -680,8 +846,10 @@ public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { var f = new Fixture(); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "full-power repair rate"); uint raw = UnitCommandStateView.ToRawEntityId(barracks); - f.Entities.GetUnitRef(barracks).CurrentHealth = 100; + f.Entities.GetUnitRef(barracks).CurrentHealth = 0; + f.Step(1); EntityId farBuilder = f.SpawnBuilder(0, 60, 60); uint farRaw = UnitCommandStateView.ToRawEntityId(farBuilder); @@ -689,16 +857,160 @@ public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() "validation checks role and damage, not reach"); f.Construction.AssignRepairOrder(farRaw, raw); f.Step(10); - Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(100), + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(0), "out of reach: the order is held, not dropped"); f.Entities.GetUnitRef(farBuilder).Transform = new Transform2D(SimFixed.FromInt(19), SimFixed.FromInt(20)); f.Step(10); - Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(200), + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(100), "10 HP per tick in reach (provisional rate)"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(975L), + "S(100)-S(0) charges exactly 25 AE"); f.Step(50); Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(600), "repair caps at MaxHealth and the order resolves"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(850L), + "repairing the full 0..Max health scale costs floor(500*30/100)=150 AE"); + f.Entities.GetUnitRef(barracks).CurrentHealth = 590; + f.Step(1); + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(590), + "the order was removed when full and does not silently re-arm"); + } + + [Test] + public void Repair_CumulativeFloorTelescopesFromOddHealth() + { + var f = new Fixture(); + EntityId power = f.Construction.PlaceCompletedBuilding(0, 5, 20, 20); + f.Entities.GetUnitRef(power).CurrentHealth = 37; + EntityId builder = f.SpawnBuilder(0, 19, 20); + f.Step(1); + f.Construction.AssignRepairOrder( + UnitCommandStateView.ToRawEntityId(builder), + UnitCommandStateView.ToRawEntityId(power)); + + f.Step(37); + + Assert.That(f.Entities.GetUnitRef(power).CurrentHealth, Is.EqualTo(400)); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(877L), + "135 - floor(135*37/400) = 123 AE with no per-tick rounding drift"); + } + + [Test] + public void Repair_LowPowerUsesFiveHp_AndZeroPriceBandStillHeals() + { + var f = new Fixture(configure: economy => economy.SetSlotFaction(0, FactionId.Legion)); + EntityId defense = f.Construction.PlaceCompletedBuilding(0, 28, 20, 20); + f.Entities.GetUnitRef(defense).CurrentHealth = 6; + EntityId builder = f.SpawnBuilder(0, 19, 20); + f.Step(1); + Assert.That(f.Economy.GetPlayerEconomy(0).IsLowPower, Is.True); + f.Construction.AssignRepairOrder( + UnitCommandStateView.ToRawEntityId(builder), + UnitCommandStateView.ToRawEntityId(defense)); + + f.Step(1); + + Assert.That(f.Entities.GetUnitRef(defense).CurrentHealth, Is.EqualTo(11), "low power halves 10 HP to 5 HP"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1000L), + "S(6) and S(11) are both 1 AE, so the zero-price floor band still heals"); + + f.Step(100); + Assert.That(f.Entities.GetUnitRef(defense).CurrentHealth, Is.EqualTo(510)); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(911L)); + + var fullPower = new Fixture(configure: economy => economy.SetSlotFaction(0, FactionId.Legion)); + EntityId fullPowerDefense = fullPower.Construction.PlaceCompletedBuilding(0, 28, 20, 20); + Assert.That(fullPower.Construction.PlaceCompletedBuilding(0, 22, 26, 20).IsValid, Is.True); + fullPower.Entities.GetUnitRef(fullPowerDefense).CurrentHealth = 6; + EntityId fullPowerBuilder = fullPower.SpawnBuilder(0, 19, 20); + fullPower.Step(1); + fullPower.Construction.AssignRepairOrder( + UnitCommandStateView.ToRawEntityId(fullPowerBuilder), + UnitCommandStateView.ToRawEntityId(fullPowerDefense)); + fullPower.Step(51); + + Assert.That(fullPower.Entities.GetUnitRef(fullPowerDefense).CurrentHealth, Is.EqualTo(510)); + Assert.That(fullPower.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(911L), + "rate 5 and rate 10 telescope to the same S(Max)-S(6)=89 AE total"); + } + + [Test] + public void Repair_TwoReachableBuilders_HealAndDebitOnlyOnce() + { + var f = new Fixture(); + EntityId target = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True); + f.Entities.GetUnitRef(target).CurrentHealth = 100; + EntityId first = f.SpawnBuilder(0, 19, 20); + EntityId second = f.SpawnBuilder(0, 19, 21); + f.Step(1); + uint targetRaw = UnitCommandStateView.ToRawEntityId(target); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(first), targetRaw); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(second), targetRaw); + + f.Step(1); + + Assert.That(f.Entities.GetUnitRef(target).CurrentHealth, Is.EqualTo(110)); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(998L), + "one target receives one S(110)-S(100) debit despite two reachable Builders"); + } + + [Test] + public void Repair_OutOfReachFirstOrder_DoesNotBlockReachableSecond() + { + var f = new Fixture(); + EntityId target = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True); + f.Entities.GetUnitRef(target).CurrentHealth = 100; + EntityId far = f.SpawnBuilder(0, 60, 60); + EntityId near = f.SpawnBuilder(0, 19, 20); + f.Step(1); + uint targetRaw = UnitCommandStateView.ToRawEntityId(target); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(far), targetRaw); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(near), targetRaw); + + f.Step(1); + + Assert.That(f.Entities.GetUnitRef(target).CurrentHealth, Is.EqualTo(110)); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(998L)); + } + + [Test] + public void Repair_InsufficientWinnerClaimsTarget_AndOtherTargetsContinue() + { + var f = new Fixture(startingCredits: 2); + EntityId hq = f.Construction.PlaceCompletedBuilding(0, 3, 20, 20); + EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 40, 20); + f.Entities.GetUnitRef(hq).CurrentHealth = 100; + f.Entities.GetUnitRef(barracks).CurrentHealth = 100; + EntityId firstHqBuilder = f.SpawnBuilder(0, 19, 20); + EntityId secondHqBuilder = f.SpawnBuilder(0, 19, 21); + EntityId barracksBuilder = f.SpawnBuilder(0, 39, 20); + f.Step(1); + + uint hqRaw = UnitCommandStateView.ToRawEntityId(hq); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(firstHqBuilder), hqRaw); + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(secondHqBuilder), hqRaw); + f.Construction.AssignRepairOrder( + UnitCommandStateView.ToRawEntityId(barracksBuilder), + UnitCommandStateView.ToRawEntityId(barracks)); + + f.Step(1); + + Assert.That(f.Entities.GetUnitRef(hq).CurrentHealth, Is.EqualTo(100), + "the first reachable HQ order claims before its 4 AE spend fails"); + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(110), + "a different target still processes in the same tick"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(0L)); + + Assert.That(f.Entities.DespawnUnit(firstHqBuilder), Is.True, "invalidate the previous winner"); + f.Economy.GetPlayerEconomy(0).AddCredits(4); + f.Step(1); + + Assert.That(f.Entities.GetUnitRef(hq).CurrentHealth, Is.EqualTo(110), + "the later same-target order stayed active and resumes after credits arrive"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(0L)); } [Test] @@ -727,7 +1039,7 @@ public void Repair_Validation_RejectsNonBuilder_AndUndamagedTarget() public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).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); @@ -745,6 +1057,7 @@ public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint() public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "influence anchor"); 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); @@ -780,7 +1093,7 @@ public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected() public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); f.SpawnBuilder(0, 19, 20); EntityId soldier = f.Entities.SpawnUnit( 0, new Transform2D(SimFixed.FromInt(50), SimFixed.FromInt(50)), SimFixed.FromInt(4), @@ -817,7 +1130,7 @@ public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation() public void ProgressSites_ReassignsNonBuilderAssignment_DefenseInDepth() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); EntityId builder = f.SpawnBuilder(0, 19, 20); f.Step(1); Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); diff --git a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs index bd2690c..cd70281 100644 --- a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs +++ b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs @@ -479,7 +479,7 @@ public void RunScript() if (tick == 40) { SubmitIntent(new PlaceBuildingPayload(refineryDef, - (ushort)(slot == 0 ? 7 : 118), (ushort)(slot == 0 ? 4 : 116))); + (ushort)(slot == 0 ? 8 : 118), (ushort)(slot == 0 ? 4 : 116))); } // The infantry marches at the enemy base: auto-acquisition // (D-087) turns this into real combat ticks on the wire. diff --git a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs index 93172c9..b69a913 100644 --- a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs @@ -207,11 +207,11 @@ public void PlaceBuilding_ThroughSealedCommands_AppliesAndRejectsDeterministical EntityId builder = host.SpawnBaseFixture(0, 4, 4); host.StepTick(); // commit the start balance (30 provided / 20 required) - // Legal: Storage (def 6, 300 AE) at (20,20) — the start grid + // Legal: Storage (def 6, 300 AE) at (4,8) — the start grid // (30 provided, 20 required) powers its 5, not the Barracks' 15: // the Alliance must build a Power plant before its Barracks // (Buildings.md power figures). - host.Submit(new PlaceBuildingPayload(6, 20, 20)); + host.Submit(new PlaceBuildingPayload(6, 4, 8)); // Insufficient funds: HQ (def 3, 2500 AE) at (30,20). host.Submit(new PlaceBuildingPayload(3, 30, 20)); host.StepTick(); @@ -231,7 +231,7 @@ public void PlaceBuilding_ThroughSealedCommands_AppliesAndRejectsDeterministical // Prerequisite: the DefensePlatform (def 11, 400 AE) needs a // completed Power plant — cheap enough that the generic cost // check passes and the domain check decides. - host.Submit(new PlaceBuildingPayload(11, 30, 30)); + host.Submit(new PlaceBuildingPayload(11, 12, 12)); host.StepTick(); Assert.That(host.Kernel.LastTickResults[0].Code, Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet)); Assert.That(host.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(700L), @@ -239,6 +239,28 @@ public void PlaceBuilding_ThroughSealedCommands_AppliesAndRejectsDeterministical Assert.That(host.Entities.IsValid(builder), Is.True); } + [Test] + public void Repair_ThroughSealedCommand_ChargesBeforeHealing() + { + var host = ProdHost.Create(Seed); + EntityId builder = host.SpawnBaseFixture(0, 4, 4); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True, "full-power anchor"); + EntityId barracks = host.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + host.Entities.GetUnitRef(builder).Transform = new Transform2D(SimFixed.FromInt(19), SimFixed.FromInt(20)); + host.Entities.GetUnitRef(barracks).CurrentHealth = 100; + host.StepTick(); + + host.Submit(new RepairPayload( + new[] { UnitCommandStateView.ToRawEntityId(builder) }, + UnitCommandStateView.ToRawEntityId(barracks))); + host.StepTick(); + + Assert.That(host.Kernel.LastTickResults[0].Code, Is.EqualTo(CommandResultCode.Applied)); + Assert.That(host.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(110)); + Assert.That(host.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(998L), + "S(110)-S(100)=2 AE is debited in the same tick before 10 HP are applied"); + } + [Test] public void QueueUnit_ThroughSealedCommands_T2GatingAndProducerRules() { @@ -277,7 +299,7 @@ public void FullLoop_BuildBarracks_QueueInfantry_SpawnsAtFootprint_OrderedToRall // Barracks' 15 — the Alliance builds its Power plant first // (Buildings.md); placed completed here, the test is about the // build/queue/spawn loop, not the power rule. - Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True); host.StepTick(); // commit the start balance // The auto-assigned fixture builder walks nowhere in this test — @@ -333,8 +355,8 @@ public void TwoKernels_ScriptedConstructionAndProduction_400Ticks_IdenticalHashe var hostB = ProdHost.Create(Seed); EntityId builderA = hostA.SpawnBaseFixture(0, 4, 4); EntityId builderB = hostB.SpawnBaseFixture(0, 4, 4); - Assert.That(hostA.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); - Assert.That(hostB.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); + Assert.That(hostA.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True); + Assert.That(hostB.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True); hostA.StepTick(); // commit the start balance on both kernels hostB.StepTick(); @@ -412,7 +434,7 @@ public void Snapshot_RestoredHost_ContinuesConstructionAndProductionIdentically( { var hostA = ProdHost.Create(Seed); EntityId builder = hostA.SpawnBaseFixture(0, 4, 4); - Assert.That(hostA.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + Assert.That(hostA.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True, "Power first — the start grid cannot power the Barracks (Buildings.md)"); hostA.StepTick(); // commit the start balance hostA.Submit(new PlaceBuildingPayload(7, 20, 20)); @@ -462,7 +484,7 @@ public void Replay_ConstructionAndProductionIntents_PlaybackReproducesEndHash() // (and therefore the playback) starts with the builder already // in reach of the future site — replay only replays commands. host.Entities.GetUnitRef(builder).Transform = new Transform2D(SimFixed.FromInt(19), SimFixed.FromInt(20)); - Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True, "Power first — the start grid cannot power the Barracks (Buildings.md)"); host.StepTick(); // commit the start balance before recording @@ -520,7 +542,7 @@ public void SetRallyPoint_OffMapCommand_IsRejected_ProductionContinuesNormally() { var host = ProdHost.Create(Seed); host.SpawnBaseFixture(0, 4, 4); - Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + Assert.That(host.Construction.PlaceCompletedBuilding(0, 5, 14, 14).IsValid, Is.True, "Power first — a low-power grid would double the production time under test"); host.StepTick(); // commit the start balance EntityId barracks = host.Construction.PlaceCompletedBuilding(0, 7, 20, 20); diff --git a/tools/Nova.SimRunner.Tests/VictorySystemTests.cs b/tools/Nova.SimRunner.Tests/VictorySystemTests.cs index 9d546bd..949b935 100644 --- a/tools/Nova.SimRunner.Tests/VictorySystemTests.cs +++ b/tools/Nova.SimRunner.Tests/VictorySystemTests.cs @@ -372,7 +372,7 @@ public void ConstructionSite_CountsAsBuilding_AndKeepsTheSideAlive() // Slot 0 gets a real construction site: power provider + builder // + credits are the placement prerequisites. - EntityId power = host.Construction.PlaceCompletedBuilding(0, DefPower, 40, 40); + EntityId power = host.Construction.PlaceCompletedBuilding(0, DefPower, 26, 20); Assert.That(power.IsValid, Is.True, "power provider"); EntityId builder = host.SpawnUnit(0, 19, 20, UnitRole.Builder); host.Step(1);