diff --git a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs index 8a8d5f8..e64e92f 100644 --- a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs +++ b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs @@ -49,7 +49,7 @@ public sealed class SkirmishAiTests /// /// End-to-end tick budget: this suite's deterministic match decides - /// at tick 2705, so 6.000 ticks is a ~2.2x margin — comfortably sane, + /// at tick 2726, so 6.000 ticks is a ~2.2x margin — comfortably sane, /// and exact because the whole loop is deterministic. /// private const int EndToEndBudgetTicks = 6000; @@ -189,10 +189,10 @@ private static void ApplyOpeningPosition(AiHost host) for (byte slot = 0; slot < 2; slot++) { ushort fieldId = (ushort)(slot + 1); - int fieldCell = slot == HumanSlot ? 7 : 119; - int hqOrigin = slot == HumanSlot ? 4 : 120; - int builderX = slot == HumanSlot ? 13 : 113; - int builderY = slot == HumanSlot ? 7 : 119; + int fieldCell = slot == HumanSlot ? 7 : 117; + int hqOrigin = slot == HumanSlot ? 4 : 118; + int builderX = slot == HumanSlot ? 13 : 111; + int builderY = slot == HumanSlot ? 7 : 117; Assert.That(host.Economy.TryAddField(fieldId, new GridPos2D(fieldCell, fieldCell), FieldReserveAE), Is.True, $"field {fieldId} could not be registered"); @@ -286,15 +286,12 @@ public void SkirmishAi_PlacesRefineryPowerThenBarracks_ThroughTheSealedCommandPa if (barracksTick == 0 && host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Barracks)) barracksTick = tick; } - Assert.Multiple(() => - { - Assert.That(refineryTick, Is.GreaterThan(0u), - "the AI must place and complete its Refinery (D-077: no prerequisite) through PlaceBuilding intents"); - Assert.That(powerTick, Is.GreaterThan(refineryTick), - "D-103 requires the AI to complete a Power plant after the Refinery and before its Barracks"); - Assert.That(barracksTick, Is.GreaterThan(powerTick), - "the AI must complete the Barracks only after its required Power plant stands"); - }); + Assert.That(refineryTick, Is.GreaterThan(0u), + "the AI must place and complete its Refinery (D-077: no prerequisite) through PlaceBuilding intents"); + Assert.That(powerTick, Is.GreaterThan(refineryTick), + "D-103 requires the AI to complete a Power plant after the Refinery and before its Barracks"); + Assert.That(barracksTick, Is.GreaterThan(powerTick), + "the AI must complete the Barracks only after its required Power plant stands"); Assert.That(host.Construction.HasFinishedBuilding(HumanSlot, UnitRole.Refinery), Is.False, "slot 0 is the passive fixture: nobody issues orders for it"); @@ -373,7 +370,7 @@ public void SkirmishAi_ProducesInfantry_AndMarchesPastMidMap() Assert.That(CountUnits(host, AiSlot, UnitRole.BasicInfantry), Is.GreaterThanOrEqualTo(6), "the Barracks keeps infantry queued — the attack threshold of the profile must be reachable"); Assert.That(MinCombatCellX(host, AiSlot), Is.LessThan(64), - "at the squad threshold the army marches toward the enemy start area (slot 1 starts at x ~ 113-122)"); + "at the squad threshold the army marches toward the enemy start area (slot 1 starts at x ~ 111-120)"); } // ---------------------------------------------------------------- diff --git a/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs b/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs index 65a4e56..f76af33 100644 --- a/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs +++ b/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs @@ -116,6 +116,8 @@ private struct FieldLayout // slot 0 Alliance (role value), slot 1 Legion (role value + 17). private const ushort DefHQAlliance = 3; private const ushort DefHQLegion = 20; + private const ushort DefRefineryAlliance = 4; + private const ushort DefRefineryLegion = 21; private sealed class ReferenceHost { @@ -196,7 +198,7 @@ private sealed class SlotLayout private static readonly SlotLayout Slot1Layout = new SlotLayout { - HqOriginX = 120, HqOriginY = 120, + HqOriginX = 118, HqOriginY = 118, BuilderX = 111, BuilderY = 117, }; @@ -401,7 +403,7 @@ public void MatchBootstrap_PlacesTheCanonicalOpeningGeometry() new Vector2Int(62, 62), })); Assert.That(bootstrap.LocalHqOrigin, Is.EqualTo(new Vector2Int(4, 4))); - Assert.That(bootstrap.EnemyHqOrigin, Is.EqualTo(new Vector2Int(120, 120))); + Assert.That(bootstrap.EnemyHqOrigin, Is.EqualTo(new Vector2Int(118, 118))); Assert.That(bootstrap.MapSize, Is.EqualTo(new Vector2Int(MapWidth, MapHeight))); Assert.That(bootstrap.Runner.Session.LocalSlot, Is.EqualTo((byte)MatchBootstrap.LocalSlot), @@ -438,6 +440,55 @@ public void ReferenceOpeningPosition_RegistersFiveFiniteFields() } } + [Test] + public void CanonicalOpeningGeometry_UsesD107PointAndFootprintMirrors() + { + Assert.That(Slot0Layout.HqOriginX + Slot1Layout.HqOriginX, Is.EqualTo(122), + "3x3 footprint origins mirror as o -> 122-o"); + Assert.That(Slot0Layout.HqOriginY + Slot1Layout.HqOriginY, Is.EqualTo(122)); + Assert.That(Slot0Layout.BuilderX + Slot1Layout.BuilderX, Is.EqualTo(124), + "point coordinates mirror as p -> 124-p"); + Assert.That(Slot0Layout.BuilderY + Slot1Layout.BuilderY, Is.EqualTo(124)); + Assert.That(FieldLayouts[0].X + FieldLayouts[1].X, Is.EqualTo(124)); + Assert.That(FieldLayouts[0].Y + FieldLayouts[1].Y, Is.EqualTo(124)); + Assert.That(FieldLayouts[2].X + FieldLayouts[3].X, Is.EqualTo(124)); + Assert.That(FieldLayouts[2].Y + FieldLayouts[3].Y, Is.EqualTo(124)); + Assert.That(FieldLayouts[4].X, Is.EqualTo(62)); + Assert.That(FieldLayouts[4].Y, Is.EqualTo(62)); + } + + [Test] + public void CanonicalOpeningGeometry_OffersEqualMirroredRefineryPlacements() + { + ReferenceHost host = BuildReferenceHost(CanonicalSeed); + ApplyOpeningPosition(host); + host.Kernel.StepTick(); // commit the two completed HQ power balances + int mismatches = 0; + int allianceApplied = 0; + int legionApplied = 0; + + for (int y = 0; y <= 122; y++) + { + for (int x = 0; x <= 122; x++) + { + CommandResultCode alliance = host.Construction.ValidatePlacement( + 0, DefRefineryAlliance, x, y); + CommandResultCode legion = host.Construction.ValidatePlacement( + 1, DefRefineryLegion, 122 - x, 122 - y); + if (alliance != legion) mismatches++; + if (alliance == CommandResultCode.Applied) allianceApplied++; + if (legion == CommandResultCode.Applied) legionApplied++; + } + } + + Assert.That(mismatches, Is.EqualTo(0), + "D-107 requires every legal or rejected refinery origin to mirror exactly"); + Assert.That(allianceApplied, Is.EqualTo(45), + "the corrected Alliance opening has 45 legal follow-up refinery origins"); + Assert.That(legionApplied, Is.EqualTo(45), + "the corrected Legion opening has the same 45 legal mirrored origins"); + } + [Test] public void ReferenceOpeningPosition_IsSeedSensitive() { diff --git a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs index dc152be..4e0557b 100644 --- a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs @@ -116,10 +116,13 @@ private static EntityId PlaceActiveDefensePlatformSite(TestHost host, byte team, FactionId faction = host.Economy.GetSlotFaction(team); ushort powerDefId = SimDefinitions.ToDefinitionId(faction, UnitRole.Power); ushort platformDefId = SimDefinitions.ToDefinitionId(faction, UnitRole.DefensePlatform); - Assert.That(host.Construction.PlaceCompletedBuilding(team, powerDefId, 50, 50).IsValid, Is.True, + EntityId power = host.Construction.PlaceCompletedBuilding(team, powerDefId, originX + 6, originY); + Assert.That(power.IsValid, Is.True, "a completed Power plant unlocks the DefensePlatform site"); host.Economy.ExecuteTick(Tick.Zero); Assert.That(host.Construction.TryPlaceBuilding(team, platformDefId, originX, originY), Is.True); + Assert.That(host.Entities.DespawnUnit(power), Is.True, + "remove the fixture anchor so combat can observe only the active site"); UnitState[] units = host.Entities.RawUnits; for (int i = 0; i < host.Entities.Capacity; i++) diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index ad14e65..259fca8 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(); } @@ -221,9 +230,9 @@ public void ValidatePlacement_ForeignFactionDefinition_IsRejectedInvalidTarget() // Legion one (id 24) — a known id naming unbuildable content is an // invalid target, exactly like an unknown one. var f = new Fixture(configure: e => e.SetSlotFaction(1, FactionId.Legion)); - Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 36, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 30, 20).IsValid, Is.True, "Legion HQ prerequisite"); - 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); @@ -242,8 +251,8 @@ public void ValidatePlacement_ForeignFactionDefinition_IsRejectedInvalidTarget() public void PlaceBuilding_LegionSlot_ChargesLegionCost_AndBuildsFaster() { var f = new Fixture(configure: e => e.SetSlotFaction(1, FactionId.Legion)); - Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 36, 40).IsValid, Is.True, "Legion HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(1, 22, 40, 40).IsValid, Is.True, "Legion power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 30, 20).IsValid, Is.True, "Legion HQ prerequisite"); + 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(110), @@ -280,8 +289,8 @@ private static uint RawOfCompleted(Fixture f, byte slot, UnitRole role) public void PlaceBuilding_ChargesExactCost_AndCreatesSiteEntity() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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) @@ -310,6 +319,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"); @@ -337,10 +347,145 @@ 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, 3, 12, 20).IsValid, Is.True, "HQ influence and prerequisite 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, 3, 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, 3, 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, 3, 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, 3, 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, 3, 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, 3, 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, 3, 8, 20).IsValid, Is.True, "HQ influence and prerequisite anchor"); + f.Step(1); + return f; + } + [Test] public void PlaceBuilding_AllPrerequisitesAreRequired_AndUnknownBitsFailClosed() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 6, 26, 20).IsValid, Is.True, + "neutral Storage influence anchor satisfies neither Barracks prerequisite"); f.SpawnBuilder(0, 19, 20); UnitRoleMask barracksPrerequisites = UnitRoleMask.HQ | UnitRoleMask.Power; @@ -349,15 +494,15 @@ public void PlaceBuilding_AllPrerequisitesAreRequired_AndUnknownBitsFailClosed() Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet), "Barracks requires both HQ and Power"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True); Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites), Is.EqualTo(UnitRoleMask.Power), "HQ alone is insufficient"); Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet)); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 34, 20).IsValid, Is.True); Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites), Is.EqualTo(UnitRoleMask.None)); Assert.That(f.Construction.HasFinishedBuildings(0, barracksPrerequisites), Is.True); - f.Step(1); // commit the balance (130 provided) + f.Step(1); // commit the balance (130 provided, Storage draws 5) Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.Applied)); var onlyPower = new Fixture(); @@ -371,6 +516,8 @@ public void PlaceBuilding_AllPrerequisitesAreRequired_AndUnknownBitsFailClosed() Is.EqualTo(UnitRoleMask.HQ), "a foreign completed building does not satisfy the mask"); var unfinished = new Fixture(startingCredits: 3000); + Assert.That(unfinished.Construction.PlaceCompletedBuilding(0, 6, 26, 20).IsValid, Is.True, + "neutral influence anchor for the HQ site"); unfinished.SpawnBuilder(0, 19, 20); Assert.That(unfinished.Construction.TryPlaceBuilding(0, 3, 20, 20), Is.True, "own HQ site"); Assert.That(unfinished.Construction.GetMissingPrerequisiteRoles(0, UnitRoleMask.HQ), @@ -389,14 +536,14 @@ public void PlaceBuilding_PowerRule_RequiresSufficientFreePower() // Committed balance: HQ 30 provided, completed VehicleFactory 25 // required -> 5 free. The factory satisfies the ResearchLab's // prerequisite, so only the power gate can reject it. - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 44, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 16, 20).IsValid, Is.True); f.Step(1); // let the economy recompute the balance Assert.That(f.Construction.HasFinishedBuildings(0, UnitRoleMask.VehicleFactory), Is.True); Assert.That(f.Construction.ValidatePlacement(0, 9, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet), "ResearchLab draws 30 but only 5 are free"); - Assert.That(f.Construction.ValidatePlacement(0, 5, 60, 60), Is.EqualTo(CommandResultCode.Applied), + Assert.That(f.Construction.ValidatePlacement(0, 5, 20, 20), Is.EqualTo(CommandResultCode.Applied), "power-providing buildings are exempt from the rule"); } @@ -408,7 +555,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)"); @@ -419,15 +566,15 @@ 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, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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); @@ -456,6 +603,8 @@ public void SiteProgress_LowPower_ExactlyHalvesProgress() Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 48, 40).IsValid, Is.True); Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True); Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 44, 40).IsValid, Is.True); + 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); @@ -479,14 +628,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(4)); + Assert.That(f.Construction.BuildingCount, Is.EqualTo(5)); } [Test] public void Completion_NormalizesLegacySiteRole_AndPowerAppliesFromNextTick() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ prerequisite"); + 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)); @@ -513,8 +662,8 @@ public void Completion_NormalizesLegacySiteRole_AndPowerAppliesFromNextTick() 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, 8, 44, 40).IsValid, Is.True); // VehicleFactory prerequisite + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True); // power + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 30, 20).IsValid, Is.True); // VehicleFactory prerequisite f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -553,7 +702,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 @@ -583,10 +732,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); @@ -595,7 +744,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); @@ -606,17 +755,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] @@ -625,9 +785,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); @@ -653,9 +817,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); @@ -710,7 +878,7 @@ public void Site_CarriesDefinitionRole_ButDrawsAndProvidesNoPower_UntilCompletio // read that role. A Refinery site drains nothing, a Power site // feeds nothing, until the site register flips at completion. var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "the completed HQ supplies the 30 power needed to permit the Refinery"); f.SpawnBuilder(0, 19, 20); f.Step(1); // commit: HQ provides 30, nothing required @@ -741,7 +909,7 @@ public void Site_CarriesDefinitionRole_ButDrawsAndProvidesNoPower_UntilCompletio public void PowerSite_ProvidesNothing_UntilCompletion() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "the completed HQ satisfies the Power-plant prerequisite and provides 30 power"); f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -761,8 +929,8 @@ public void PowerSite_ProvidesNothing_UntilCompletion() public void CancelConstruction_Refunds75Percent_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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 @@ -785,8 +953,8 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint() public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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); @@ -808,9 +976,9 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() public void CancelConstruction_RefundIsCappedAtStorageCeiling() { var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ provides power and the 2,000 AE ceiling"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "the completed Power plant satisfies the Barracks prerequisite"); f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -848,11 +1016,13 @@ public void SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay() public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, - "a completed power plant keeps the normal repair rate active"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, + "HQ keeps the starting credits inside the D-106 capacity"); 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); @@ -860,16 +1030,166 @@ 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); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 26, 20).IsValid, Is.True, + "HQ keeps the starting credits inside the D-106 capacity"); + 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); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 23, 26, 20).IsValid, Is.True, + "Storage raises capacity without providing power"); + 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); + Assert.That(fullPower.Construction.PlaceCompletedBuilding(0, 23, 30, 20).IsValid, Is.True, + "Storage keeps credits inside the D-106 capacity"); + 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] @@ -919,8 +1239,8 @@ public void Repair_Validation_RejectsNonBuilder_AndUndamagedTarget() public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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); @@ -934,11 +1254,144 @@ public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint() Assert.That(f.Construction.IsCellFree(20, 20), Is.True); } + [Test] + public void SnapshotRestore_ReplacesPriorDynamicFootprintsInCostField() + { + var source = new Fixture(); + var restored = new Fixture(); + Assert.That(source.Construction.PlaceCompletedBuilding(0, 3, 4, 4).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 3, 4, 4).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 5, 10, 4).IsValid, Is.True, + "the target host starts with an extra footprint absent from the snapshot"); + Assert.That(restored.CostField.IsWalkable(10, 4), Is.False); + + byte[] snapshotBlock = WriteConstructionState(source); + Assert.That(restored.Construction.TryRestoreState(snapshotBlock), Is.True); + + Assert.That(restored.CostField.IsWalkable(4, 4), Is.False, + "the footprint present in the snapshot must be rebuilt"); + Assert.That(restored.CostField.IsWalkable(10, 4), Is.True, + "a footprint present only in the prior live state must be removed"); + Assert.That(restored.Construction.ValidatePlacement(0, 5, 10, 4), Is.EqualTo(CommandResultCode.Applied), + "D-104 placement must not observe stale dynamic obstacles after restore"); + } + + [Test] + public void RepairOrderRemoval_SnapshotContinuation_PreservesWinnerOrder() + { + var live = new Fixture(startingCredits: 2); + var restored = new Fixture(startingCredits: 2); + + Assert.That(live.Construction.PlaceCompletedBuilding(0, 3, 4, 4).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 3, 4, 4).IsValid, Is.True); + Assert.That(live.Construction.PlaceCompletedBuilding(0, 5, 10, 4).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 5, 10, 4).IsValid, Is.True); + EntityId liveFirstTarget = live.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + EntityId restoredFirstTarget = restored.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + EntityId liveSecondTarget = live.Construction.PlaceCompletedBuilding(0, 7, 26, 20); + EntityId restoredSecondTarget = restored.Construction.PlaceCompletedBuilding(0, 7, 26, 20); + EntityId liveFirstBuilder = live.SpawnBuilder(0, 25, 20); + EntityId restoredFirstBuilder = restored.SpawnBuilder(0, 25, 20); + EntityId liveSecondBuilder = live.SpawnBuilder(0, 19, 20); + EntityId restoredSecondBuilder = restored.SpawnBuilder(0, 19, 20); + live.Entities.GetUnitRef(liveFirstTarget).CurrentHealth = 100; + restored.Entities.GetUnitRef(restoredFirstTarget).CurrentHealth = 100; + live.Entities.GetUnitRef(liveSecondTarget).CurrentHealth = 100; + restored.Entities.GetUnitRef(restoredSecondTarget).CurrentHealth = 100; + live.Step(1); + restored.Step(1); + + uint firstBuilderRaw = UnitCommandStateView.ToRawEntityId(liveFirstBuilder); + uint secondBuilderRaw = UnitCommandStateView.ToRawEntityId(liveSecondBuilder); + uint firstTargetRaw = UnitCommandStateView.ToRawEntityId(liveFirstTarget); + uint secondTargetRaw = UnitCommandStateView.ToRawEntityId(liveSecondTarget); + Assert.That(UnitCommandStateView.ToRawEntityId(restoredFirstBuilder), Is.EqualTo(firstBuilderRaw)); + Assert.That(UnitCommandStateView.ToRawEntityId(restoredSecondBuilder), Is.EqualTo(secondBuilderRaw)); + Assert.That(UnitCommandStateView.ToRawEntityId(restoredFirstTarget), Is.EqualTo(firstTargetRaw)); + Assert.That(UnitCommandStateView.ToRawEntityId(restoredSecondTarget), Is.EqualTo(secondTargetRaw)); + + live.Construction.AssignRepairOrder(firstBuilderRaw, firstTargetRaw); + live.Construction.AssignRepairOrder(secondBuilderRaw, firstTargetRaw); + restored.Construction.AssignRepairOrder(firstBuilderRaw, firstTargetRaw); + restored.Construction.AssignRepairOrder(secondBuilderRaw, firstTargetRaw); + live.Construction.ClearRepairOrder(firstBuilderRaw); + restored.Construction.ClearRepairOrder(firstBuilderRaw); + + byte[] snapshotBlock = WriteConstructionState(live); + Assert.That(restored.Construction.TryRestoreState(snapshotBlock), Is.True); + live.Construction.AssignRepairOrder(firstBuilderRaw, secondTargetRaw); + restored.Construction.AssignRepairOrder(firstBuilderRaw, secondTargetRaw); + Assert.That(WriteConstructionState(restored), Is.EqualTo(WriteConstructionState(live)), + "removal plus restore must retain the dense authoritative order before appending"); + + live.Step(1); + restored.Step(1); + Assert.That(restored.Kernel.CalculateStateHash(), Is.EqualTo(live.Kernel.CalculateStateHash())); + Assert.That(live.Entities.GetUnitRef(liveFirstTarget).CurrentHealth, Is.EqualTo(110), + "the surviving older order spends the only 2 AE first"); + Assert.That(live.Entities.GetUnitRef(liveSecondTarget).CurrentHealth, Is.EqualTo(100), + "the appended order is atomically refused after the credits are spent"); + Assert.That(restored.Entities.GetUnitRef(restoredFirstTarget).CurrentHealth, + Is.EqualTo(live.Entities.GetUnitRef(liveFirstTarget).CurrentHealth)); + Assert.That(restored.Entities.GetUnitRef(restoredSecondTarget).CurrentHealth, + Is.EqualTo(live.Entities.GetUnitRef(liveSecondTarget).CurrentHealth)); + } + + [Test] + public void SiteRemoval_SnapshotContinuation_PreservesAppendOrder() + { + var live = new Fixture(startingCredits: 5000); + var restored = new Fixture(startingCredits: 5000); + Assert.That(live.Construction.PlaceCompletedBuilding(0, 3, 20, 20).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 3, 20, 20).IsValid, Is.True); + Assert.That(live.Construction.TryPlaceBuilding(0, 5, 26, 20), Is.True); + Assert.That(restored.Construction.TryPlaceBuilding(0, 5, 26, 20), Is.True); + uint liveFirstSite = UnitCommandStateView.ToRawEntityId(SiteEntity(live)); + uint restoredFirstSite = UnitCommandStateView.ToRawEntityId(SiteEntity(restored)); + Assert.That(live.Construction.TryPlaceBuilding(0, 5, 20, 26), Is.True); + Assert.That(restored.Construction.TryPlaceBuilding(0, 5, 20, 26), Is.True); + Assert.That(live.Construction.CancelConstruction(liveFirstSite), Is.True); + Assert.That(restored.Construction.CancelConstruction(restoredFirstSite), Is.True); + + byte[] snapshotBlock = WriteConstructionState(live); + Assert.That(restored.Construction.TryRestoreState(snapshotBlock), Is.True); + Assert.That(live.Construction.TryPlaceBuilding(0, 5, 26, 26), Is.True); + Assert.That(restored.Construction.TryPlaceBuilding(0, 5, 26, 26), Is.True); + + Assert.That(WriteConstructionState(restored), Is.EqualTo(WriteConstructionState(live)), + "a new site must append after the surviving site on both live and restored hosts"); + Assert.That(restored.Kernel.CalculateStateHash(), Is.EqualTo(live.Kernel.CalculateStateHash())); + } + + [Test] + public void BuildingRemoval_SnapshotContinuation_PreservesAppendOrder() + { + var live = new Fixture(); + var restored = new Fixture(); + EntityId liveFirst = live.Construction.PlaceCompletedBuilding(0, 3, 10, 20); + EntityId restoredFirst = restored.Construction.PlaceCompletedBuilding(0, 3, 10, 20); + Assert.That(liveFirst.IsValid, Is.True); + Assert.That(restoredFirst.IsValid, Is.True); + Assert.That(live.Construction.PlaceCompletedBuilding(0, 3, 20, 20).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 3, 20, 20).IsValid, Is.True); + Assert.That(live.Construction.SellBuilding(UnitCommandStateView.ToRawEntityId(liveFirst)), Is.True); + Assert.That(restored.Construction.SellBuilding(UnitCommandStateView.ToRawEntityId(restoredFirst)), Is.True); + + byte[] snapshotBlock = WriteConstructionState(live); + Assert.That(restored.Construction.TryRestoreState(snapshotBlock), Is.True); + Assert.That(live.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True); + + Assert.That(WriteConstructionState(restored), Is.EqualTo(WriteConstructionState(live)), + "a new completed placement must append after the survivor on both hosts"); + Assert.That(restored.Kernel.CalculateStateHash(), Is.EqualTo(live.Kernel.CalculateStateHash())); + } + [Test] public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ prerequisite"); + 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); @@ -974,8 +1427,8 @@ public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected() public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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), @@ -1012,8 +1465,8 @@ public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation() public void ProgressSites_ReassignsNonBuilderAssignment_DefenseInDepth() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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); @@ -1050,5 +1503,12 @@ private static EntityId SiteEntity(Fixture f) } throw new System.InvalidOperationException("no site entity found"); } + + private static byte[] WriteConstructionState(Fixture fixture) + { + var writer = new SnapshotBlockWriter(); + fixture.Construction.WriteState(writer); + return writer.ToArray(); + } } } diff --git a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs index fd13859..4ae1aa1 100644 --- a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs @@ -176,8 +176,10 @@ public void HarvestThenReturn_ThroughSealedCommands_RaisesCreditsExactly() public void ReturnCargo_HoldsAtRefinerySite_ThenDepositsAtCompletedRefinery() { var host = EcoHost.Create(Seed); - Assert.That(host.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + Assert.That(host.Construction.PlaceCompletedBuilding(0, 3, 2, 10).IsValid, Is.True, "the completed HQ supplies the placement power budget"); + Assert.That(host.Economy.TryAddField(63, new GridPos2D(10, 14), 9000), Is.True, + "a Refinery needs a registered field at footprint distance 1 through 3"); host.StepTick(); Assert.That(host.Construction.TryPlaceBuilding(0, 4, 10, 10), Is.True, "the nearby definition-role Refinery is still only a site"); diff --git a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs index aab66bc..a567e3f 100644 --- a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs @@ -504,9 +504,9 @@ public void CapacityFor_CountsCompletedStorage_AndExcludesSites() kernel.RegisterSystem(economy); kernel.Start(); Assert.That(construction.PlaceCompletedBuilding( - 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 12, 20).IsValid, Is.True); Assert.That(construction.PlaceCompletedBuilding( - 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 36, 40).IsValid, Is.True, + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 16, 20).IsValid, Is.True, "the completed Refinery satisfies the Storage prerequisite"); kernel.StepTick(); // commit the grid (30 provided) for the placement power rule @@ -547,6 +547,9 @@ public void CapacityFor_HqSiteAlone_ProvidesNoAccountBase() var economy = new EconomySystem(entities, startingCredits: 3000); var construction = new ConstructionSystem(entities, economy); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Power), 12, 20).IsValid, Is.True, + "a completed Power plant supplies D-104 influence without adding account capacity"); Assert.That(construction.TryPlaceBuilding( 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 20, 20), Is.True); Assert.That(economy.CapacityFor(0), Is.EqualTo(0L), diff --git a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs index c91787f..22fe811 100644 --- a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs +++ b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs @@ -79,15 +79,26 @@ public void ComputeHash_IsStableAcrossInstances_AndStubHashesAreDistinct() } [Test] - public void CurrentRulesHash_MovesPastRevisionOne_ForLowPowerRepair() + public void RulesRevisionOneAndTwo_GoldenHashesRemainByteStable() { ulong revisionOne = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV1); + ulong revisionTwo = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2); + + Assert.That(revisionOne, Is.EqualTo(0x531CE8F614A16CB5UL), "revision 1 canonical stream is frozen"); + Assert.That(revisionTwo, Is.EqualTo(0x07725EA26668C9F8UL), "revision 2 canonical stream is frozen"); + } + + [Test] + public void CurrentRulesHash_MovesPastRevisionTwo_ForD104PlacementAndRepair() + { + ulong revisionTwo = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2); ulong current = MatchFingerprint.ComputeCurrentRulesHash64(); - Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV2)); - Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2))); - Assert.That(current, Is.Not.EqualTo(revisionOne), - "Sprint-16.6 C4 repair behavior must not share D-106's revision-1 rules identity"); + Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV3)); + Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3))); + Assert.That(current, Is.EqualTo(0x05CCA8475789AD4AUL), "revision 3 canonical stream is frozen"); + Assert.That(current, Is.Not.EqualTo(revisionTwo), + "D-104 placement and repair behavior must not share revision 2's rules identity"); } [Test] diff --git a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs index 82a39ca..e42fe53 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/ReplayV1Tests.cs b/Assets/Tests/EditMode/Simulation/ReplayV1Tests.cs index 0c8896c..578ee37 100644 --- a/Assets/Tests/EditMode/Simulation/ReplayV1Tests.cs +++ b/Assets/Tests/EditMode/Simulation/ReplayV1Tests.cs @@ -220,6 +220,29 @@ public void FingerprintMismatch_RevisionOneRules_RefusesPlaybackBeforeTickOne() "revision-1 rules must be refused before execution"); } + [Test] + public void FingerprintMismatch_RevisionTwoRules_RefusesPlaybackBeforeTickOne() + { + ReplayV1TestUtil.LiveMatch live = ReplayV1TestUtil.RunLiveMatch(); + MatchFingerprint revisionTwo = MatchFingerprint.CreateCurrent( + MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2), + live.Fingerprint.DefinitionsHash64, live.Fingerprint.MapHash64, + live.Fingerprint.GetSlotOccupancyCopy(), live.Fingerprint.GetSlotFactionCopy(), + live.Fingerprint.StartSeed, live.Fingerprint.InitialStateHash, + live.Fingerprint.InputDelayTicks); + + ReplayV1TestUtil.TestHost playback = ReplayV1TestUtil.CreatePlaybackHost(); + Assert.That( + ReplayPlayer.TryPlay( + live.ReplayBytes, revisionTwo, playback.Kernel, playback.Ingress, + out ReplayPlaybackError error, out string detail), + Is.False); + Assert.That(error, Is.EqualTo(ReplayPlaybackError.FingerprintMismatch)); + StringAssert.Contains("RulesHash64", detail); + Assert.That(playback.Kernel.CurrentTick.Value, Is.EqualTo(0u), + "revision-2 rules must be refused before execution"); + } + [Test] public void FingerprintMismatch_DifferentSlotOccupancy_RefusesPlayback() { diff --git a/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs b/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs index 3162115..a18df2f 100644 --- a/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs @@ -244,7 +244,8 @@ public void HqSite_DoesNotSaveTheSlot_FromTheHqLossElimination() // register is excluded from the HQ scan, exactly like the generic // role was before. TestHost host = NewHost(startingCredits: 6000); - host.SpawnUnit(0, 10, 10, UnitRole.HQ); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 3, 10, 10).IsValid, Is.True, + "the real HQ must be a tracked completed placement for D-104 influence"); host.SpawnUnit(0, 16, 10, UnitRole.Builder); host.SpawnUnit(1, 50, 50, UnitRole.HQ); host.SpawnUnit(1, 52, 50); @@ -253,7 +254,7 @@ public void HqSite_DoesNotSaveTheSlot_FromTheHqLossElimination() // Slot 0 starts a second HQ as a SITE — definition role HQ since // 16.3, 1 HP, never completed in this test (the builder stands // out of reach, so the site pauses). - Assert.That(host.Construction.TryPlaceBuilding(0, 3, 30, 30), Is.True, "HQ def 3 (Alliance)"); + Assert.That(host.Construction.TryPlaceBuilding(0, 3, 18, 10), Is.True, "HQ def 3 (Alliance)"); Assert.That(host.Construction.SiteCount, Is.EqualTo(1)); // The real HQ falls: the D-077 elimination must fire despite the @@ -404,8 +405,9 @@ public void ConstructionSite_CountsAsBuilding_AndKeepsTheSideAlive() // Slot 0 gets a real DefensePlatform site: power provider + builder // + credits are the placement prerequisites. It deliberately has // no HQ, so D-077's separate last-HQ defeat trigger cannot mask - // the D-056 site-counting behavior under test. - EntityId power = host.Construction.PlaceCompletedBuilding(0, DefPower, 40, 40); + // the D-056 site-counting behavior under test. The provider stays + // within D-104 construction influence of the site. + 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/Tests/PlayMode/GrayboxDemoProofTests.cs b/Assets/Tests/PlayMode/GrayboxDemoProofTests.cs index 90be845..7cd826b 100644 --- a/Assets/Tests/PlayMode/GrayboxDemoProofTests.cs +++ b/Assets/Tests/PlayMode/GrayboxDemoProofTests.cs @@ -6,6 +6,7 @@ using UnityEngine.TestTools; using Nova.Gameplay.Match; using Nova.Simulation.CommandsV1; +using Nova.Simulation.Economy; namespace Nova.PlayMode.Tests { @@ -54,7 +55,7 @@ public IEnumerator SceneViews_RenderOverviewAndBothBases() CaptureFrom($"{ShotDir}/demo_03_overview.png", new Vector3(64f, 110f, 24f), new Vector3(64f, 0f, 64f)); CaptureFrom($"{ShotDir}/demo_04_base_alliance.png", new Vector3(5f, 30f, -12f), new Vector3(6f, 0f, 6f)); - CaptureFrom($"{ShotDir}/demo_05_base_legion.png", new Vector3(121f, 30f, 138f), new Vector3(120f, 0f, 120f)); + CaptureFrom($"{ShotDir}/demo_05_base_legion.png", new Vector3(119f, 30f, 136f), new Vector3(119f, 0f, 119f)); foreach (string shot in new[] { @@ -140,9 +141,11 @@ public IEnumerator BootstrapMatch_RunsRendersAndStartsTheLoop() uint tickBaseline = runner.Session.CurrentTick; long creditsBaseline = runner.Economy.GetPlayerEconomy(0).AetheriumCredits; Assert.Greater(tickBaseline, 0u, "Session tick did not advance"); - Assert.AreEqual(3000L, creditsBaseline, - "the D-077 opening starts with 3.000 AE and NO income of its own — " + - "no pre-placed refinery, no starting harvesters"); + Assert.AreEqual(3000L, bootstrap.ActiveConfig.StartingCredits, + "D-077 configures the canonical 3.000-AE opening"); + Assert.That(creditsBaseline, + Is.InRange(EconomySystem.HqBaseCapacityAE, 2999L), + "D-106 deterministically decays the initial overhang while no completed Storage exists"); Camera camera = Camera.main; Assert.NotNull(camera, "no main camera in the Bootstrap scene"); @@ -153,6 +156,10 @@ public IEnumerator BootstrapMatch_RunsRendersAndStartsTheLoop() // classic first move — the Alliance Refinery (definition id 4) // beside the field — enters through the sealed command intake, // exactly as device input would submit it. + Assert.AreEqual( + CommandResultCode.Applied, + runner.Construction.ValidatePlacement(MatchBootstrap.LocalSlot, 4, 8, 4), + "the canonical local Refinery origin must remain legal before command submission"); Assert.AreEqual( CommandIngressResult.Accepted, runner.Ingress.TrySubmitIntent( @@ -170,8 +177,8 @@ public IEnumerator BootstrapMatch_RunsRendersAndStartsTheLoop() "the player's placed refinery exists as a construction site (counted slot-scoped: " + "the skirmish AI builds its own refinery on slot 1 in the same match, so the " + "global Construction.SiteCount is no longer a player-only signal)"); - Assert.AreEqual(2300L, creditsLater, - $"3000 - 700 refinery cost — the D-077 loop start is player-driven (was {creditsBaseline})"); + Assert.AreEqual(creditsBaseline - 700L, creditsLater, + $"current credits - 700 refinery cost — the D-077 loop start is player-driven (was {creditsBaseline})"); CaptureFrame(camera, $"{ShotDir}/demo_02_economy.png"); yield return null; @@ -289,7 +296,7 @@ private static int CountSites(MatchRunner runner, byte slot) for (int i = 0; i < runner.Entities.Capacity; i++) { ref readonly Nova.Simulation.State.UnitState u = ref units[i]; - if (!u.IsActive || u.PlayerId != slot || u.Role != Nova.Simulation.State.UnitRole.Unit) continue; + if (!u.IsActive || u.PlayerId != slot) continue; uint raw = Nova.Simulation.State.UnitCommandStateView.ToRawEntityId(u.Id); if (raw != 0 && runner.Construction.TryGetSite(raw, out _, out _, out _)) { diff --git a/Assets/_Project/Data/Maps/MAP_Glutrinne.asset b/Assets/_Project/Data/Maps/MAP_Glutrinne.asset index 0c117ad..b80c671 100644 --- a/Assets/_Project/Data/Maps/MAP_Glutrinne.asset +++ b/Assets/_Project/Data/Maps/MAP_Glutrinne.asset @@ -18,7 +18,7 @@ MonoBehaviour: _height: 128 _spawnPoints: - {x: 5, y: 5} - - {x: 121, y: 121} + - {x: 119, y: 119} _resourceNodes: - {x: 7, y: 7} - {x: 117, y: 117} diff --git a/Assets/_Project/Editor/BootstrapSceneGenerator.cs b/Assets/_Project/Editor/BootstrapSceneGenerator.cs index dd442e6..693e88c 100644 --- a/Assets/_Project/Editor/BootstrapSceneGenerator.cs +++ b/Assets/_Project/Editor/BootstrapSceneGenerator.cs @@ -290,8 +290,8 @@ private static void EnsureGlutrinneMapAsset() MapBiomeType.Desert, 128, 128, - // HQ footprint centres of the canonical opening (4,4)+(3x3) and its 180° mirror. - new[] { new Vector2(5f, 5f), new Vector2(121f, 121f) }, + // D-107 HQ footprint centres: point mirror p -> 124-p. + new[] { new Vector2(5f, 5f), new Vector2(119f, 119f) }, // The five fields MatchBootstrap registers, in canonical id order. new[] { diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs index 0a2d431..363b599 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs @@ -153,11 +153,11 @@ private struct FieldLayout /// The five canonical fields (16.7, C1 — MVPContentManifest section 5, /// mirror of Determinism10000Scenario.FieldLayouts): two start fields /// and two natural expansions at 9.000 AE each, one contested centre - /// at 15.000. Symmetry is binding: every slot-1 coordinate is the - /// point mirror of slot 0 through the map centre ((x, y) -> - /// (124 - x, 124 - y), the same mirror that maps HQ (4,4) to HQ - /// (120,120)), so both starts are equally far from their expansion - /// (36 cells) and from the centre (58). + /// at 15.000. Symmetry is binding: point coordinates mirror as + /// (x, y) -> (124 - x, 124 - y); a 3x3 footprint's lower-left + /// origin therefore mirrors as (x, y) -> (122 - x, 122 - y). + /// D-107 maps HQ (4,4) to HQ (118,118), so both starts are equally + /// far from their expansion (34 cells) and from the centre (56). /// private static readonly FieldLayout[] FieldLayouts = { @@ -250,7 +250,7 @@ private struct FieldLayout /// Aetherium field cell of the human player (7, 7). public Vector2Int LocalFieldCell => new Vector2Int(LocalPlayerLayout.FieldX, LocalPlayerLayout.FieldY); - /// Aetherium field cell of the opponent (117, 117 since 16.7 — the exact centre mirror). + /// Aetherium field cell of the opponent (117, 117 — the D-102/D-107 Glutrinne layout-axis mirror). public Vector2Int EnemyFieldCell => new Vector2Int(EnemyPlayerLayout.FieldX, EnemyPlayerLayout.FieldY); /// @@ -274,7 +274,7 @@ public Vector2Int[] AllFieldCells /// Lower-left footprint origin of the human HQ (4, 4). public Vector2Int LocalHqOrigin => new Vector2Int(LocalPlayerLayout.HqOriginX, LocalPlayerLayout.HqOriginY); - /// Lower-left footprint origin of the opponent HQ (120, 120). + /// Lower-left footprint origin of the opponent HQ (118, 118). public Vector2Int EnemyHqOrigin => new Vector2Int(EnemyPlayerLayout.HqOriginX, EnemyPlayerLayout.HqOriginY); /// Center cell of the human HQ footprint — the natural camera start focus. @@ -992,12 +992,12 @@ private sealed class SlotLayout BuilderX = 13, BuilderY = 7, }; - /// Opponent base, top-right: the exact centre mirror of . + /// Opponent base, top-right: the exact D-107 footprint mirror of . private static readonly SlotLayout EnemyLayout = new SlotLayout { Slot = EnemySlot, FieldId = 2, FieldX = 117, FieldY = 117, - HqOriginX = 120, HqOriginY = 120, + HqOriginX = 118, HqOriginY = 118, BuilderX = 111, BuilderY = 117, }; } diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index dc341cd..60e799d 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -34,22 +34,25 @@ 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 roles 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 roles, 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. D-103 represents every - /// other prerequisite as a fail-closed all-of mask over completed own - /// roles (for example Barracks needs HQ + Power, VehicleFactory needs - /// Refinery + Barracks, and Radar needs Power + Barracks). - /// bypasses placement validation - /// entirely — it is the direct write the match start is placed with. + /// startStatePerPlayer): placed through the command path it is gated by + /// funds, D-104 geometry and the power rule. D-103 represents every other + /// prerequisite as a fail-closed all-of mask over completed own roles (for + /// example Barracks needs HQ + Power, VehicleFactory needs Refinery + + /// Barracks, and Radar needs Power + Barracks). + /// bypasses 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 @@ -108,11 +111,12 @@ 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. Under LOW POWER the rate halves exactly - /// (16.6, C4 — Economy.md repair rule; 10/2 is exact, no rounding). + /// damaged building. In reach (same Chebyshev rule) the target gains + /// HP per tick, or exactly + /// 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 , @@ -167,6 +171,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; + /// Repair rate in HP per tick while the owner's grid is in LOW POWER (C4, Sprint 16.6). public const int LowPowerRepairRateHpPerTick = 5; @@ -370,10 +390,11 @@ private static UnitRoleMask RoleMask(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 roles, - /// 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 + /// roles, 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) { @@ -393,6 +414,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 (!HasFinishedBuildings(playerSlot, def.PrerequisiteRoles)) { return CommandResultCode.RejectedPrerequisitesNotMet; @@ -501,11 +529,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 @@ -556,6 +583,7 @@ public bool CancelConstruction(uint rawEntityId) _entityManager.DespawnUnit(id); FreeFootprint(site.OriginX, site.OriginY); site.IsActive = false; + CompactSites(); return true; } @@ -581,6 +609,7 @@ public bool SellBuilding(uint rawEntityId) _entityManager.DespawnUnit(id); FreeFootprint(placement.OriginX, placement.OriginY); placement.IsActive = false; + CompactBuildings(); return true; } @@ -605,6 +634,7 @@ public void ClearRepairOrder(uint builderRaw) if (index >= 0) { _repairs[index].IsActive = false; + CompactRepairOrders(); } } @@ -646,6 +676,8 @@ private void SweepDeadPlacements() _buildings[i].IsActive = false; } } + CompactSites(); + CompactBuildings(); } private void ProgressSites() @@ -693,6 +725,7 @@ private void ProgressSites() CompleteSite(i, in def, siteUnit.PlayerId); } } + CompactSites(); } private void CompleteSite(int siteIndex, in SimBuildingDefinition def, byte ownerSlot) @@ -839,6 +872,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]; @@ -846,7 +888,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; @@ -859,32 +903,91 @@ 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 } - // 16.6 (C4, Economy.md repair rule): LOW POWER halves the - // repair rate — 10/2 is exact, no rounding. The last stage of - // the shutdown order still repairs; it just repairs slower. - int rate = RepairRateHpPerTick; - ref readonly PlayerEconomyState repairEco = ref _economy.GetPlayerEconomy(target.PlayerId); - if (repairEco.IsLowPower) + // 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 + ? LowPowerRepairRateHpPerTick + : 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)) { - rate = LowPowerRepairRateHpPerTick; + continue; // atomic refusal: no debit and no healing } - int repaired = target.CurrentHealth + rate; - target.CurrentHealth = repaired > target.MaxHealth ? target.MaxHealth : repaired; + target.CurrentHealth = healthAfter; + if (healthAfter >= target.MaxHealth) + { + order.IsActive = false; + } } + CompactRepairOrders(); + } + + 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; } // ------------------------------------------------------------------ @@ -977,6 +1080,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; @@ -1135,6 +1353,42 @@ private int FreeRepairIndex() return -1; } + private void CompactSites() + { + int write = 0; + for (int read = 0; read < MaxSites; read++) + { + if (!_sites[read].IsActive) continue; + if (write != read) _sites[write] = _sites[read]; + write++; + } + for (int i = write; i < MaxSites; i++) _sites[i] = default; + } + + private void CompactBuildings() + { + int write = 0; + for (int read = 0; read < MaxBuildings; read++) + { + if (!_buildings[read].IsActive) continue; + if (write != read) _buildings[write] = _buildings[read]; + write++; + } + for (int i = write; i < MaxBuildings; i++) _buildings[i] = default; + } + + private void CompactRepairOrders() + { + int write = 0; + for (int read = 0; read < MaxRepairOrders; read++) + { + if (!_repairs[read].IsActive) continue; + if (write != read) _repairs[write] = _repairs[read]; + write++; + } + for (int i = write; i < MaxRepairOrders; i++) _repairs[i] = default; + } + // ------------------------------------------------------------------ // Snapshot block 105 (v1) // ------------------------------------------------------------------ @@ -1206,6 +1460,20 @@ public bool TryRestoreState(ReadOnlySpan blockContent) { if (!TryParseState(blockContent, out ParsedConstruction parsed)) return false; + // Remove the pre-restore dynamic footprint overlay before + // replacing the authoritative placement tables. Clearing only + // _occupied would leave cells that exist solely in the old live + // state impassable in the shared CostField and make placement or + // pathfinding after restore depend on the target host's history. + for (int i = 0; i < MaxSites; i++) + { + if (_sites[i].IsActive) FreeFootprint(_sites[i].OriginX, _sites[i].OriginY); + } + for (int i = 0; i < MaxBuildings; i++) + { + if (_buildings[i].IsActive) FreeFootprint(_buildings[i].OriginX, _buildings[i].OriginY); + } + Array.Copy(parsed.Sites, _sites, MaxSites); Array.Copy(parsed.Buildings, _buildings, MaxBuildings); Array.Copy(parsed.Repairs, _repairs, MaxRepairOrders); diff --git a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs index 3391cb3..b29591f 100644 --- a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs +++ b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs @@ -340,6 +340,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/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs b/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs index 22f76cd..bdc97a6 100644 --- a/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs +++ b/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs @@ -3,6 +3,7 @@ using Nova.Core; using Nova.Simulation.CommandsV1; using Nova.Simulation.Construction; +using Nova.Simulation.Definitions; using Nova.Simulation.Economy; using Nova.Simulation.Snapshots; @@ -116,8 +117,14 @@ public sealed class MatchFingerprint : IEquatable /// public const ushort RulesRevisionV2 = 2; + /// + /// D-104 deterministic placement geometry and paid, single-winner + /// repair behavior layered onto revision 2. + /// + public const ushort RulesRevisionV3 = 3; + /// The rules revision emitted by current hosts. - public const ushort CurrentRulesRevision = RulesRevisionV2; + public const ushort CurrentRulesRevision = RulesRevisionV3; /// Parser bound for one identifier string; checked before allocation. public const int MaxIdentifierBytes = 64; @@ -265,14 +272,18 @@ public static ulong ComputeEmptyContentStubHash(MatchContentStub stub) /// public static ulong ComputeRulesHash64(ushort rulesRevision) { - if (rulesRevision != RulesRevisionV1 && rulesRevision != RulesRevisionV2) + if (rulesRevision != RulesRevisionV1 + && rulesRevision != RulesRevisionV2 + && rulesRevision != RulesRevisionV3) { throw new ArgumentOutOfRangeException(nameof(rulesRevision), rulesRevision, "Unknown rules revision."); } var hash = SimHashWriter.ForDefinitions(); hash.WriteFieldTag((uint)MatchContentStub.Rules); - hash.WriteUInt32(rulesRevision == RulesRevisionV1 ? 5u : 7u); // ordered rule fields below + hash.WriteUInt32(rulesRevision == RulesRevisionV1 + ? 5u + : rulesRevision == RulesRevisionV2 ? 7u : 14u); // ordered rule fields below hash.WriteFieldTag(1); hash.WriteUInt16(rulesRevision); hash.WriteFieldTag(2); @@ -290,13 +301,31 @@ public static ulong ComputeRulesHash64(ushort rulesRevision) hash.WriteFieldTag(7); hash.WriteInt32(ConstructionSystem.LowPowerRepairRateHpPerTick); } + if (rulesRevision >= RulesRevisionV3) + { + hash.WriteFieldTag(8); + hash.WriteInt32(SimDefinitions.BuildingFootprintCells); + hash.WriteFieldTag(9); + hash.WriteInt32(ConstructionSystem.RepairCostPercent); + hash.WriteFieldTag(10); + hash.WriteInt32(ConstructionSystem.BuildInfluenceRadiusCells); + hash.WriteFieldTag(11); + hash.WriteInt32(ConstructionSystem.MinimumBuildingDistanceCells); + hash.WriteFieldTag(12); + hash.WriteInt32(ConstructionSystem.RefineryMinimumFieldDistanceCells); + hash.WriteFieldTag(13); + hash.WriteInt32(ConstructionSystem.RefineryMaximumFieldDistanceCells); + hash.WriteFieldTag(14); + hash.WriteInt32(ConstructionSystem.MinimumNonRefineryFieldDistanceCells); + } return hash.Digest(); } /// - /// Canonical rules identity for the current simulation. Revision 2 - /// binds both D-106 economy behavior and the Sprint-16.6 C4 low-power radar - /// and repair behavior that can diverge without changing snapshot + /// Canonical rules identity for the current simulation. Revision 3 + /// retains D-106 and Sprint-16.6 C4 from revisions 1/2, then binds the + /// D-104 footprint, influence, field-spacing, repair-price and + /// single-winner behavior that can diverge without changing snapshot /// bytes or definition rows. Old/new peers and replays therefore fail /// the exact-fingerprint gate before executing tick 1. /// diff --git a/CHANGELOG.md b/CHANGELOG.md index 6096b44..a8299a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -216,6 +216,23 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de gespulte Partie ohne dieses Etikett nichts wert ist. ### Geändert +- **16.9/C6: Bauplätze und Reparaturen kosten Raum und Aetherium (D-104).** + Alle MS-1-Gebäude verwenden den einheitlichen 3×3-Footprint; 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. Sites, fertige + Platzierungen und Reparaturaufträge werden nach Entfernen stabil kompaktiert, + sodass Append-Reihenfolge und Gewinner auch über Snapshot-Restore identisch + bleiben. Ein Restore entfernt zusätzlich alte dynamische CostField-Footprints + des Zielhosts, bevor er exakt die Snapshot-Belegung neu aufbaut. `RulesHash64` + steigt append-only auf Revision 3; V1/V2 bleiben + eingefroren und alte Replays oder Peers werden vor Tick 1 abgelehnt. D-107 + korrigiert dazu den zweiten HQ-Ursprung von `(120,120)` auf `(118,118)` an + der bestehenden Glutrinne-Layoutachse: beide Seiten erhalten 45/45 legale + gespiegelte Folge-Raffinerieplätze. Der integrierte kanonische KI-Ausgang + bewegt sich dadurch bei unveränderter Kennung `r7.E34435F9` von Tick 2.705 / + `0x28F2CC571BCE6B76` auf Tick 2.726 / `0x10B83E94F86F2E55`. - **16.8/D-103: Bauvoraussetzungen sind jetzt echte All-of-Ketten.** Eine separate `UnitRoleMask` ersetzt das singuläre `PrerequisiteRole`, beide Fraktionen verwenden dieselbe neun Rollen umfassende Kette, und Executor wie diff --git a/README.md b/README.md index 00948c7..9c33194 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Project Nova -**Dokumentversion:** 0.21.0 | **Status:** unveröffentlichter Entwicklungsstand, spielbar | **Verantwortungsbereich:** Executive Producer / Technical Writer | **Stand:** 2026-08-10 +**Dokumentversion:** 0.22.2 | **Status:** unveröffentlichter Entwicklungsstand, spielbar | **Verantwortungsbereich:** Executive Producer / Technical Writer | **Stand:** 2026-08-10 > Ein Echtzeitstrategiespiel in der Tradition von **Command & Conquer** — Basisbau, > Ernte, Armee, Karte kontrollieren. Gebaut mit Unity und C#, offen entwickelt. @@ -136,12 +136,12 @@ gemacht, das man starten, bedienen und gewinnen kann: Dazu durchgehend: **Fraktionsidentität** (Allianz und Legion unterscheiden sich in Schadensmatrix, Waffenwerten, Kosten und Harvester-Kapazität), ein -deterministischer Simulationskern mit **549/549 bestandenen Headless-Tests**, -eine Unity-Baseline von **521/521 EditMode** und **8/9 PlayMode** — einziger -Fehler ist der bekannte headless-`RenderTexture`-Fall — sowie **34 3D-Assets**, -die über eine Drop-in-Pipeline einfahren (§5). Die nach dem Unity-Lauf -ergänzten Audio-/Death-Hold-Assertions sind kompiliert, aber nicht erneut -ausgeführt. +deterministischer Simulationskern mit **706/706 bestandenen Headless-Tests** auf +dem integrierten 16.9-Stand sowie **34 3D-Assets**, die über eine Drop-in-Pipeline +einfahren (§5). Auf demselben Stand sind außerdem die vollständige Unity- +EditMode-Suite mit **587/587** Tests und die fokussierte Graybox-Proof- +PlayMode-Suite mit **3/3** Tests im gepinnten Editor grün gelaufen. Das ist +automatisierte Laufzeitevidenz, keine behauptete manuelle Spielabnahme. ### Was noch fehlt @@ -156,10 +156,10 @@ funktionierender Loop: - **Gefechtsdichte ist noch nicht manuell abgenommen.** VFX und SFX sind implementiert; die geplante Sicht- und Gegenhörprüfung mit 60 Einheiten bleibt offen. -- **Strang C läuft weiter.** Fünf endliche Aetheriumfelder, Lagergrenze, Radar - und Low-Power-Wirkung sind umgesetzt; Mehrfachvoraussetzungen, - Platzierungsregeln, Reparaturkosten und ehrliches Blocker-Feedback folgen - noch in den nachgelagerten Paketen. +- **Strang C läuft weiter.** Fünf endliche Aetheriumfelder, Lagergrenze, Radar, + Low-Power-Wirkung, Mehrfachvoraussetzungen sowie Platzierungs- und + Reparaturkosten sind umgesetzt; das ehrliche Blocker- und Energiefeedback am + Entscheidungspunkt folgt noch in Paket 16.10. - **Kein Speichern.** Die Simulation kann ihren Zustand vollständig serialisieren und hash-identisch fortsetzen — es fehlt nur das Schreiben auf die Platte. @@ -285,9 +285,9 @@ Runde*. - **Netzwerk und Gefechtsdichte sind noch nicht vollständig manuell abgenommen.** A8 Stufen 2–4 sowie die 60-Einheiten-Sicht-/Gegenhörprüfung bleiben offen. -- **Strang C läuft weiter.** Wirtschaftsdruck, Lager, Radar und Low Power sind - da; Mehrfachvoraussetzungen, Platzierungsregeln, Reparaturkosten und das - vollständige Entscheidungsfeedback fehlen noch. +- **Strang C läuft weiter.** Wirtschaftsdruck, Lager, Radar, Low Power, + Mehrfachvoraussetzungen, Platzierungsregeln und Reparaturkosten sind da; nur + das vollständige Entscheidungsfeedback aus Paket 16.10 fehlt noch. Die vollständigen Grenzen stehen im aktuellen [Sprintbericht](docs/production/hashkrieg/12_Sprint_Zu_Zweit.md). @@ -434,9 +434,8 @@ nicht rückwirkend übertragen. ## Offene Punkte -- **Die verbleibenden Strang-C-Pakete aus §2** — Voraussetzungen, - Platzierung/Reparaturkosten und Entscheidungsfeedback — sind die wichtigste - offene Umsetzungsfolge. +- **Das verbleibende Strang-C-Paket 16.10** — korrektes Blocker-, Energie- und + Stop-Feedback — ist die wichtigste offene Umsetzungsfolge. - Der Umbenennungsbeschluss auf *Hashkrieg* ist im Bestand dieses Repositories noch nicht vollzogen — Repo, Code und Wiki laufen weiter unter *Project Nova*. - Q-018 (Preis) und Q-019 (Telemetrie) bleiben offen und blockieren MS-1 nicht. @@ -449,8 +448,8 @@ Zur Bewertung, in dieser Reihenfolge: 2–4; Linux/systemd und der Live-Workflow müssen ebenfalls real laufen. 2. **Gefechtsfeedback manuell abnehmen** — ungefähr 60 feuernde Einheiten, SFX-Regler, Klangbalance und Kamera-Listener gegenhören und ansehen. -3. **Strang C abschließen** — Mehrfachvoraussetzungen, - Platzierungs-/Reparaturregeln und ehrliches Blocker-Feedback integrieren. +3. **Strang C abschließen** — ehrliches Blocker-, Energie- und Stop-Feedback + aus Paket 16.10 integrieren. 4. **Attack-Move** — Truppen sollen unterwegs Gegner bekämpfen, ohne jeden Kontakt einzeln befohlen zu bekommen. 5. **KI-Ausbau** — der Gegner spielt, aber weiterhin schlicht; die neuen @@ -468,6 +467,9 @@ Projekt ein Publikum hat. | 0.19.1 | 2026-08-08 | CLA-Wirkung auf Beiträge mit dokumentierter Zustimmung begrenzt; keine rückwirkende Rechteübertragung unterstellt | Technical Writer | | 0.20.0 | 2026-08-10 | D-105: alleinige Projektleitung und Merge-Autorität von `@cubetribe`, externe aktuelle Inhaberfreigabe sowie die ehrliche Zurückstellung manueller Spielabnahmen dokumentiert | Technical Writer | | 0.21.0 | 2026-08-10 | Strang-C-Status auf #73/#77/#78/#80 nachgezogen: Lagergrenze, Radar, Low Power und fünf endliche Aetheriumfelder sind umgesetzt; die tatsächlich verbleibenden Pakete bleiben offen | Codex / Dennis Westermann | +| 0.22.0 | 2026-08-10 | Strang-C-Status auf D-103/D-104/D-107 nachgezogen: All-of-Voraussetzungen, footprintbasierte Platzierung, bezahlte Reparatur und faire Glutrinne-Startgeometrie sind umgesetzt; nur Paket 16.10 bleibt offen | Codex / Dennis Westermann | +| 0.22.1 | 2026-08-10 | Die veraltete Unity-Baseline durch die tatsächlich gelaufenen Nachweise 586/586 EditMode und 3/3 fokussierte Graybox-PlayMode-Tests ersetzt und klar von einer manuellen Spielabnahme getrennt | Codex / Dennis Westermann | +| 0.22.2 | 2026-08-10 | Nach dem Snapshot-Restore-Fix die erneut gelaufenen Nachweise auf 706/706 Headless- und 587/587 EditMode-Tests fortgeschrieben | Codex / Dennis Westermann | | 0.7.1 | 2026-07-24 | Recovery-Baseline nach Implementierungs-Audit | Executive Producer / Lead Technical Director | | 0.8.0 | 2026-07-24 | Closed-Core MS-1, exakten Engine-Pin, G0-offenen Status und Quality-Verträge D-056–D-061 aufgenommen | Executive Producer / Technical Writer | | 0.8.1 | 2026-07-24 | Evidence-Semantikvalidator ergänzt und Dokumentstruktur korrigiert | Technical Writer / Lead QA Engineer | 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/Buildings.md b/docs/gamedesign/Buildings.md index 5d7a2b1..e8c41b5 100644 --- a/docs/gamedesign/Buildings.md +++ b/docs/gamedesign/Buildings.md @@ -1,6 +1,6 @@ # Gebäude – alle Fraktionen -**Version:** 0.7.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 16 +**Version:** 0.8.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 16 ## Zweck @@ -8,7 +8,7 @@ Spielbare Spezifikation aller 12 Gebäudetypen (D-008) für die drei Fraktionen ## Abhängigkeiten -- [../production/DecisionLog.md](../production/DecisionLog.md) – D-008 (12 Typen), D-010 (Aetherium-Wirtschaft), D-011 (Evolvierte-Wachstum), D-012 (Zerstörbarkeit), D-022 (Capture-System), D-023 (Superwaffen-Limit), D-024 (Lager & Raffinerie), D-030 (Low-Power/Forschung), D-077 (MS-1-Startzustand), D-096 (abgeleitete AE-Grenze), D-103 (MS-1-Bauvoraussetzungen), D-106 (zustandsloser Überhang) +- [../production/DecisionLog.md](../production/DecisionLog.md) – D-008 (12 Typen), D-010 (Aetherium-Wirtschaft), D-011 (Evolvierte-Wachstum), D-012 (Zerstörbarkeit), D-022 (Capture-System), D-023 (Superwaffen-Limit), D-024 (Lager & Raffinerie), D-030 (Low-Power/Forschung), D-077 (MS-1-Startzustand), D-096 (abgeleitete AE-Grenze), D-103 (MS-1-Bauvoraussetzungen), D-104 (MS-1-Platzierung und Reparatur), D-106 (zustandsloser Überhang) - [./Factions.md](./Factions.md) – Fraktionsidentitäten - [./Economy.md](./Economy.md) – AE-Währung, Energie, Low-Power-Regel, Harvester-Werte, Lager-Kapazität (D-024) - [./ResearchTree.md](./ResearchTree.md) – Tech-Tiers 1–3 @@ -45,6 +45,21 @@ fertiggestelltes Gebäude vorhanden sein; Baustellen genügen nicht. | Radar | Kraftwerk **und** Kaserne | | Verteidigungsplattform (Basis) | Kraftwerk | +Alle neun MS-1-Gebäude besitzen einen einheitlichen **3×3-Footprint**. D-104 +misst Gelände, Baueinfluss sowie Gebäude- und Feldabstände am vollständigen +Footprint in der Chebyshev-Metrik: jede Zelle muss begehbar sein; ein eigenes, +lebendes und fertiges HQ, Lager oder Kraftwerk muss höchstens acht Zellen +entfernt sein; zu Baustellen und lebenden fertigen Gebäuden gilt mindestens +Abstand 2. Feldüberlappung ist verboten. Raffinerien brauchen zu mindestens +einem registrierten Feld Abstand 1 bis 3, alle anderen Gebäude zu jedem Feld +mindestens Abstand 2; auch erschöpfte Felder bleiben Kartenmerkmale. + +Allianz und Legion reparieren mit **10 TP/Tick**, bei Low Power mit +**5 TP/Tick**. Die vollständige Lebensleiste kostet kumulativ genau 30 % des +Neupreises; die ganzzahlige Abrechnung erfolgt zustandslos aus dem +Gesundheitsstand. Reicht AE für einen Tick nicht, bleiben AE und TP unverändert. +Je Ziel wirkt pro Tick nur der erste valide, erreichbare Auftrag. + Ein fertiggestelltes Forschungslabor schaltet T2 unmittelbar frei; Forschung, Forschungsqueue und T3 sind deaktiviert. Die Voraussetzung der Verteidigungsplattform meint nur das Basisgebäude. Die Modulfreischaltungen aus @@ -56,7 +71,7 @@ Bei Widerspruch übersteuert dieser Abschnitt den nachfolgenden Vollspielentwurf - **Führende Quelle für Gebäudewerte (Review F-03, Grundsatzregel D-047):** Dieses Dokument ist die alleinige Quelle für Gebäudekosten, Energiewerte (Erzeugung/Verbrauch) und Bauzeiten. [./Economy.md](./Economy.md) verweist hierher und enthält nur Systemlogik (Einkommensraten, Low-Power-Regel, Lager-Kapazität); doppelte Zahlenpflege ist unzulässig. - **12 Typen pro Fraktion, identische Rollen, eigene Namen und Werte-Deltas** (D-008, D-011). Die Rolle ist fraktionsübergreifend balancierbar; Kosten/TP/Bauzeit tragen die Fraktionsidentität (Allianz teuer/präzise, Legion günstig/massig, Evolvierte regenerativ/wachsend). -- **Energiebilanz:** Kraftwerke produzieren, fast alles andere verbraucht. Defizit löst die Low-Power-Regel aus: Produktions-, Bau- und Forschungsgeschwindigkeit −50 % (D-030), Radar und Verteidigungsplattformen offline, Superwaffen-Ladung pausiert (D-030) (Zahlengerüst). +- **Energiebilanz:** Kraftwerke produzieren, fast alles andere verbraucht. Im ausgelieferten MS-1 halbiert ein Defizit Produktion, Bau und Reparatur; Radar und Minimap fallen aus. Forschung, Superwaffen-Ladung und stromabhängige Verteidigungsplattformen bleiben Ziele des nachfolgenden Vollspielentwurfs und sind im aktuellen Combat-Pfad nicht als Low-Power-Effekt implementiert. - **Lagerkapazität (D-024/D-096/D-106):** Genau eine HQ-Basiskapazität von 2.000 AE je Konto, sobald mindestens ein fertiges HQ lebt; +2.000 AE je fertigem Lager. Neue Einzahlungen werden hart gedeckelt. Vorhandener Überhang verliert unabhängig von seiner Ursache einmal pro Sekunde 25 % des aktuellen Überhangs (Abrundung, mindestens 1 AE) bis zur Grenze. - **Eroberung (D-022):** Feindliche Gebäude sind eroberbar – eine Capture-Einheit kanalisiert 5 s am Ziel (Abbruch bei Schaden) und wird bei Erfolg verbraucht. Capture-Einheiten: Engineer (Allianz), Saboteur (Legion), Tunnelgräber (Evolvierte). Regelwerk und Details in [./Infantry.md](./Infantry.md) und [./NeutralUnits.md](./NeutralUnits.md); gilt ebenso für neutrale Geschütztürme (D-016). - **Trefferpunkt-Klassen:** Leicht (400–800 TP), Mittel (900–1.500 TP), Schwer (1.600–2.500 TP). Klasse statt Pseudo-Präzision; exakte Werte entstehen im Balancing-Pass. @@ -177,7 +192,7 @@ Voraussetzungsschlüssel: HQ = Hauptquartier, KW = Kraftwerk, Raff = Raffinerie, | Legion | Geschützstellung | 300 AE | 10 s | −8 | Mittel | | Evolvierte | Rankenturm | 350 AE | 11 s (Reifung) | −8 | Mittel | -- Rolle: stationäre Verteidigung; Basis ist ein unbewaffnetes Podest, Bewaffnung über Module (§3). Offline bei Low Power. +- Rolle: stationäre Verteidigung; Basis ist ein unbewaffnetes Podest, Bewaffnung über Module (§3). „Offline bei Low Power“ bleibt Vollspielziel; der aktuelle MS-1-Combat-Pfad schaltet die Plattform bei Strommangel noch nicht ab. - **Aggressions-Modi (D-031.6):** Die Plattform nutzt dieselben drei Aggressions-Modi wie Einheiten – **Halten / Abwehren (Standard) / Freies Feuer** –, umschaltbar über die Befehlskachel der Auswahl; Logik identisch zu [../vision/CoreGameplay.md](../vision/CoreGameplay.md) (Abschnitt Aggressions-Modi). Der Modus gilt unverändert für alle Modul-Stufen (MG/Flak/Rakete). - Voraussetzung: Kaserne (MG-Modul), Radar (Flak-Modul), FL (Raketen-Modul). @@ -231,18 +246,36 @@ Module werden auf dem fertigen Podest installiert (nicht als separates Gebäude) ## 6. Detail: Platzierungsregeln -- **Baugrid:** einheitliches quadratisches Grid (Deckung mit Pathfinding-/FoW-Grid, siehe Maps.md); Gebäudefootprints von 2×2 (Lager, Radar, Plattform) bis 4×4 (HQ, Fabriken, Superwaffe); Rotieren in 90°-Schritten. -- **Einflussradius:** Neubauten nur innerhalb des Radius (8 Zellen) eines eigenen HQ, Lagers oder Kraftwerks; Evolvierte zusätzlich innerhalb des Herzkristall-/Wachstumsknoten-Radius. -- **Abstand zu Aetherium-Feldern:** nur Raffinerien (und Evolvierte-Bauten zwecks Wachstumsbonus, §7) dürfen direkt angrenzen (max. 3 Zellen); alle anderen Gebäude mindestens 2 Zellen Abstand – verhindert Feld-Blockade und hält Harvester-Routen frei (D-010). -- **Kollision:** kein Bau auf Feldern, Steilhängen, Wasser (D-013), Brücken, neutralen Strukturen oder im Mindestabstand von 1 Zelle zu anderen Gebäuden (Ausnahme Mauern). -- Platzierungs-Vorschau zeigt valide (grün) / invalide (rot) Zellen inkl. Begründungs-Tooltip. +- **MS-1 (D-104):** einheitliches quadratisches Grid und einheitlicher + 3×3-Footprint; keine Rotation. Jede Footprintzelle muss im gemeinsamen + Pathfinding-Kostenfeld begehbar und unbelegt sein. +- **Einflussradius:** footprintbasierter Chebyshev-Abstand höchstens 8 zu einem + eigenen, lebenden, fertigen HQ, Lager oder Kraftwerk. +- **Aetheriumfelder:** keine Footprintüberlappung. Raffinerien brauchen Abstand + 1 bis 3 zu mindestens einem registrierten Feld; alle anderen Gebäude Abstand + mindestens 2 zu jedem Feld. Erschöpfte Felder zählen weiterhin. +- **Gebäudering:** Abstand mindestens 2 zu jeder aktiven Baustelle und jedem + lebenden fertigen Gebäude; damit bleibt ein voller Zellenring leer. +- Die Platzierungsvorschau zeigt valide (grün) und invalide (rot) Zellen. Der + exakte Sperrgrund am Bauknopf gehört zu Paket 16.10. +- **Vollspielziel:** variable 2×2- bis 4×4-Footprints, Rotation, Evolvierte- + Einfluss und weitere Terrain-/Neutralregeln werden erst außerhalb von MS-1 + entschieden und implementiert. ## 7. Detail: Verkauf, Reparatur, Evolvierte-Wachstum (D-011) **Allianz & Legion** - Verkauf: 50 % Rückerstattung, 3 s Abbau, jederzeit außer unter Beschuss. -- Reparatur: Baufahrzeug oder Repair-Drohne (D-014); Kosten 30 % des Neupreises für volle Reparatur, Reparaturrate ~2 % TP/s. +- **MS-1-Reparatur (D-104):** Builder reparieren 10 TP/Tick, bei Low Power + 5 TP/Tick. Für `R = floor(Kosten × 30 / 100)` und + `S(h) = floor(R × clamp(h, 0, MaxTP) / MaxTP)` kostet ein Tick + `S(h1) − S(h0)`. Die vollständige Leiste kostet dadurch kumulativ exakt + 30 % ohne Rundungsdrift oder zusätzliches Zustandsfeld. Fehlendes AE lehnt + Abbuchung und Heilung atomar ab; pro Ziel wirkt nur der erste valide, + erreichbare Auftrag in stabiler Reihenfolge. +- Repair-Drohnen und die Vollspiel-Zielrate von ungefähr 2 % TP/s sind nicht + Teil des ausgelieferten MS-1. **Evolvierte (Wachstum statt Konstruktion)** @@ -265,14 +298,17 @@ Module werden auf dem fertigen Podest installiert (nicht als separates Gebäude) - **Tor-Modul (Post-MVP):** Mauern und Tore sind nicht Teil von MS-1. Ein späterer Scope entscheidet Durchlass- und Torregel gemeinsam neu. - **Flak-DPS-Korridor: entschieden (D-047, Korrekturlauf Sprint 4).** [Weapons.md](./Weapons.md) ist die einzige Werte-Quelle für das Flak-Modul (Reichweite 11–12 Felder ≙ m, Schaden 25–40 pro Schuss / 1,5 s Abklingzeit, ×2,0-Multiplikator gegen Luft ≈ 33–53 DPS effektiv); [Aircraft.md](./Aircraft.md) wurde im selben Korrekturlauf auf diesen Korridor angeglichen (der frühere Wert 90 DPS ist überholt und entfällt). Punkt geschlossen. -- **Footprints (Status: Abgleich mit Maps.md läuft):** Exakte Footprints (2×2 bis 4×4) sind Annahmen; Finalisierung mit Maps.md. +- **Vollspiel-Footprints:** Die variable Staffelung 2×2 bis 4×4 bleibt offen; + MS-1 ist durch D-104 verbindlich und vollständig auf 3×3 festgelegt. Entschieden und entfernt im Korrekturlauf Sprint 4: HQ-Grundenergie (+30 führend in diesem Dokument, D-032 – war bereits als geschlossen markiert). ## Nächste Schritte - Abgleich der AE-/Energie-Werte mit Economy.md (Harvester-Raten, Low-Power-Definition, HQ-Grundenergie +30, Lagerwerte D-024) und der Tier-Freischaltungen mit ResearchTree.md. -- Footprint- und Grid-Finalisierung mit Maps.md; KI-Bauplatzierungs-Regeln als Input für AIArchitecture. +- Variable Vollspiel-Footprints mit Maps.md finalisieren; die MS-1-Regeln sind + durch D-104 festgelegt. KI-Bauplatzierungs-Regeln bleiben Input für + AIArchitecture. - Balancing-Pass v0.2 nach erstem spielbaren Skirmish-Prototyp (Sprint 3+). ## Änderungsverlauf @@ -288,3 +324,4 @@ Entschieden und entfernt im Korrekturlauf Sprint 4: HQ-Grundenergie (+30 führen | 0.5.0 | 2026-07-24 | Neun Gebäuderollen, Start-Ausnahme, T2-Freischaltung und MG-/Raketenmodule für MS-1 gemäß D-056 abgegrenzt | Lead Gameplay Designer | | 0.6.0 | 2026-08-10 | MS-1-Startwert (D-077) und Lagervertrag (D-106) nachgezogen: eine 2.000-AE-HQ-Basis je Konto, +2.000 je fertigem Lager und periodischer 25-%-Abbau des aktuellen Überhangs statt separatem Zerstörungsabzug | Codex / Dennis Westermann | | 0.7.0 | 2026-08-10 | Die neun fraktionsgleichen All-of-Platzierungsvoraussetzungen samt Trennung von Plattformbasis und Modulen gemäß D-103 festgeschrieben | Agent (unter Delegation) / Dennis Westermann | +| 0.8.0 | 2026-08-10 | D-104-MS-1-Override ergänzt: einheitliche 3×3-Footprints, footprintbasierte Einfluss-, Gelände-, Gebäude- und Feldabstände sowie kumulative 30-%-Reparaturkosten mit 10/5 TP pro Tick; nicht implementierte Vollspielziele klar abgegrenzt | Agent (unter Delegation) / Dennis Westermann | diff --git a/docs/gamedesign/Economy.md b/docs/gamedesign/Economy.md index 9d3e9ac..2146874 100644 --- a/docs/gamedesign/Economy.md +++ b/docs/gamedesign/Economy.md @@ -1,6 +1,6 @@ # Wirtschaftssystem (Economy) -**Version:** 0.5.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 16 +**Version:** 0.6.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 16 ## Zweck @@ -8,7 +8,7 @@ Spezifiziert den Wirtschafts-Kreislauf von Project Nova: Sammler-Loop, Lagerkapa ## Abhängigkeiten -- [../production/DecisionLog.md](../production/DecisionLog.md) – D-008 (12 Gebäudetypen), D-010 (Hybridwirtschaft, Matchdauer), D-011 (Evolvierte-Wachstum/Regeneration), D-014 (Drohnen), D-015 (Elite-Einheiten), D-016 (Objective-Belohnungen), D-024 (Lager & Raffinerie), D-027 (Regenerations-Bonus), D-030 (Low-Power & Forschung), D-077 (MS-1-Startzustand), D-096/D-106 (abgeleitete AE-Grenze und Überhang) +- [../production/DecisionLog.md](../production/DecisionLog.md) – D-008 (12 Gebäudetypen), D-010 (Hybridwirtschaft, Matchdauer), D-011 (Evolvierte-Wachstum/Regeneration), D-014 (Drohnen), D-015 (Elite-Einheiten), D-016 (Objective-Belohnungen), D-024 (Lager & Raffinerie), D-027 (Regenerations-Bonus), D-030 (Low-Power & Forschung), D-077 (MS-1-Startzustand), D-096/D-106 (abgeleitete AE-Grenze und Überhang), D-104 (kumulative MS-1-Reparaturkosten) - [./Resources.md](./Resources.md) – Feldregeln, Nachwuchsraten, Überernte - [./Factions.md](./Factions.md) – Fraktionsprofile (Allianz teuer/präzise, Legion günstig/Masse, Evolvierte organisch) - [./Buildings.md](./Buildings.md) – **führend für Gebäudekosten, Energiewerte und Bauzeiten** (Review F-03, Grundsatzregel D-047); Bauvoraussetzungen, Evolvierte-Reifung, HQ-Grundenergie @@ -108,8 +108,8 @@ nach ~4–6 min, Tier 3 nach ~12–15 min erreichbar – passend zur Ziel-Matchd | 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) | @@ -165,3 +165,4 @@ Leitplanke: Gesamt-AE-Fluss pro Spieler über ein typisches 25-min-Match ≈ 25. | 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-10 | MS-1-Start auf 3.000 AE (D-077) nachgezogen und D-106 präzisiert: eine HQ-Basis je Konto, +2.000 je fertigem Lager, harte Einzahlungskappung sowie zustandsloser 25-%-Abbau des aktuellen Überhangs pro Sekunde | Codex / Dennis Westermann | +| 0.6.0 | 2026-08-10 | 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/gamedesign/Maps.md b/docs/gamedesign/Maps.md index 8175f6c..b7ba285 100644 --- a/docs/gamedesign/Maps.md +++ b/docs/gamedesign/Maps.md @@ -1,6 +1,6 @@ # Karten (Maps) -**Version:** 0.3.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Level Designer | **Sprint:** 2 +**Version:** 0.4.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Level Designer | **Sprint:** 16 ## Zweck @@ -8,7 +8,7 @@ Definiert das Layout-Regelwerk für alle Project-Nova-Karten: Größenklassen, S ## Abhängigkeiten -- [../production/DecisionLog.md](../production/DecisionLog.md) – D-010 (Aetherium-Hybridwirtschaft, Matchdauer 20–35 Min), D-012 (zerstörbare Elemente), D-013 (Wasser nur Terrain-Feature), D-016 (Neutrale), D-017 (Biome/Karten-Roadmap), D-018 (Modi-Staffelung), D-019 (Kamera/Lesbarkeit), D-022 (Capture-Kanal, Brücken-Reparatur), D-025 (FFA-Klarstellung), D-028 (Karten-/Biome-Festlegungen: Hazards, Doppelbelegung, Eisbruch-Fallback, Brücken reparierbar, Survival auf Standard-Karten) +- [../production/DecisionLog.md](../production/DecisionLog.md) – D-010 (Aetherium-Hybridwirtschaft, Matchdauer 20–35 Min), D-012 (zerstörbare Elemente), D-013 (Wasser nur Terrain-Feature), D-016 (Neutrale), D-017 (Biome/Karten-Roadmap), D-018 (Modi-Staffelung), D-019 (Kamera/Lesbarkeit), D-022 (Capture-Kanal, Brücken-Reparatur), D-025 (FFA-Klarstellung), D-028 (Karten-/Biome-Festlegungen), D-102 (fünf endliche Glutrinne-Felder), D-107 (kanonische Glutrinne-Layoutachse) - [./Biomes.md](./Biomes.md) – Biom-Profile, Wetter/Hazards, Biom-Modifikatoren - [./NeutralUnits.md](./NeutralUnits.md) – Regeln und Belohnungen für Critters, feindliche Lager, capturebare Geschütztürme - [./MultiplayerModes.md](./MultiplayerModes.md) – Modus-Regeln und -Staffelung (Survival-Anforderungen) @@ -27,6 +27,15 @@ bei 1 m/Zelle, klares Wetter und zwei symmetrische Hauptangriffswege. | natürliche Expansion | 2 | 9.000 AE | | zentrales Feld | 1 | 15.000 AE | +Die ausgelieferte Glutrinne bewahrt gemäß D-102/D-107 ihre Layoutachse durch +Zelle `(62,62)`: Punkte spiegeln als `(x,y) → (124-x,124-y)`, die unteren +linken Ursprünge der einheitlichen 3×3-MS-1-Gebäude als +`(x,y) → (122-x,122-y)`. Deshalb liegen die beiden HQ-Ursprünge bei `(4,4)` +und `(118,118)`. Diese MS-1-Festlegung ist eine bewusste Ausnahme vom +nachfolgenden generischen Vollspielziel einer Spiegelung an der physischen +Kartenmitte; eine spätere Rezentrierung wäre eine eigene Karten- und +Balanceentscheidung. + Es gibt keine Neutralen, Brücken, Capture-Ziele, Wettereffekte, Hazards oder Umgebungszerstörung; nur Aetherium darf sich verändern beziehungsweise dauerhaft beschädigt werden. Alle weiteren Karten, Größen und Biome im @@ -166,3 +175,4 @@ Jede Karte ist ein flacher Datensatz (ScriptableObject-tauglich): `MapId, BiomeI | 0.1.0 | 2026-07-21 | Erstfassung: Größenklassen, Layout-Regeln, Feld-Mengengerüst, Roadmap, Produktionsprozess | Lead Level Designer | | 0.2.0 | 2026-07-21 | Korrekturlauf Sprint 2 (D-020–D-030) | Lead Level Designer | | 0.3.0 | 2026-07-24 | Glutrinne mit Raster, Feldern, Routen und expliziten Nicht-Zielen als einzige MS-1-Karte gemäß D-056 festgelegt | Lead Level Designer | +| 0.4.0 | 2026-08-10 | D-102/D-107-Override ergänzt: fünf endliche Felder, Glutrinne-Layoutachse durch `(62,62)` und abgeleitete Spiegelung der einheitlichen 3×3-MS-1-Footprints | Agent (unter Delegation) / Dennis Westermann | diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md index 5bb5322..01d9e11 100644 --- a/docs/production/DecisionLog.md +++ b/docs/production/DecisionLog.md @@ -1,6 +1,6 @@ # Decision Log -**Version:** 1.37.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 16 +**Version:** 1.39.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 16 ## Zweck @@ -3115,6 +3115,85 @@ persistentes Zustandsformat wird in diesem Paket geändert. --- +### 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. Umsetzung und Integrationsnachweis folgen in Paket 16.9 +auf die bereits integrierten Pakete 16.7 und 16.8. + +**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. Aktive Reparaturaufträge bilden dafür + eine dichte, reihenfolgestabile Folge: Entfernen kompaktiert, neue Aufträge + werden angehängt. Snapshot und Fortsetzung behalten dadurch dieselbe + Gewinnerreihenfolge ohne neues Zustandsfeld. +8. Die deterministischen D-104-Regeln erhalten eine append-only + `RulesRevisionV3` im Match-Fingerprint. V1 und V2 bleiben als historische + Berechnungen erhalten; Builds vor und nach D-104 gelten absichtlich als + inkompatibel. +9. Aktive Baustellen und fertige Platzierungen verwenden denselben dichten, + reihenfolgestabilen Entfernen-/Anhängen-Vertrag wie Reparaturaufträge. Das + persistierte Format speichert keine Tabellenlöcher; Live-Fortsetzung und + Snapshot-Restore dürfen deshalb niemals unterschiedliche Folgeplätze + vergeben. + +**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. Die dichte +Auftragsfolge macht diese Reihenfolge auch über Snapshot-Restore kanonisch; +dieselbe Dichte hält die Platzierungsregister restore-transparent. + +**Konsequenzen:** Platzierung und Reparatur verändern deterministisches +Simulationsverhalten und deshalb den `RulesHash64`, 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. + +--- + ### D-105 | verbindlich | Sprint 16 (alleinige Projektleitung und Merge-Autorität) **Status:** unmittelbar wirksame Inhaberentscheidung vom 2026-08-10 (Dennis @@ -3294,6 +3373,62 @@ Sekunde. Mehrere HQs geben weiterhin nur 2.000 AE Basis. Spiegeltests in .NET und Unity sichern Mehrfach-HQ, HQ-Baustelle, echte Lagerzerstörung, Konvergenz, `long.MaxValue` und die Ablehnung des alten Rules-Stubs ab. +--- + +### D-107 | verbindlich | Sprint 16 (Glutrinne-Layoutachse für Punkte und 3×3-Footprints) + +**Status:** Agentenentscheidung vom 2026-08-10 unter ausdrücklicher +Inhaberdelegation; überstimmbar. + +**Kontext:** D-102 spiegelt die kanonischen Glutrinne-Feld- und Builderpunkte +über die bestehende Layoutachse durch `(62,62)` mit +`(x,y) → (124-x,124-y)`. Das ist die ausgelieferte D-102-Layoutachse, nicht die +physische Mittelpunktspiegelung des vollständigen 128×128-Rasters. Der zweite +HQ-Ursprung wurde bislang mit derselben +Formel von `(4,4)` nach `(120,120)` gespiegelt. Für einen 3×3-Footprint ist das +geometrisch falsch: die belegten Rechtecke `(4..6)` und `(120..122)` sind keine +Spiegelbilder. D-104 machte den Unterschied messbar. Die Startfelder lagen in +Footprint-Abstand 1 beziehungsweise 3, die Expansionen in Abstand 34 +beziehungsweise 36 und das Zentrum in Abstand 56 beziehungsweise 58; daraus +entstanden 45 gegen 57 legale erste Raffinerie-Ursprünge. Builder und das +deterministische Raffineriedrehbuch folgten bereits der korrekten Punkt- und +Footprintspiegelung. + +**Alternativen:** + +1. Den zweiten HQ-Ursprung auf `(118,118)` verschieben und die etablierte + Punktachse sowie Felder, Builder und Raffineriedrehbuch unverändert lassen. +2. Beide HQs beibehalten und stattdessen sämtliche Feld-, Builder-, + Raffinerie- und Assetkoordinaten auf eine andere Achse verschieben. +3. Die asymmetrischen Startbedingungen als Kartenmerkmal tolerieren. + +**Entscheidung:** Alternative 1. Für die Punkte des kanonischen +Glutrinne-Layouts gilt weiterhin `T(x,y) = (124-x,124-y)`. Für den unteren +linken Ursprung eines +3×3-Footprints folgt daraus `T3(x,y) = (122-x,122-y)`. Die HQ-Ursprünge sind +damit `(4,4)` und `(118,118)`, ihre Mittelpunkte `(5,5)` und `(119,119)`. +Felder, Builder und das Raffineriedrehbuch bleiben unverändert. + +**Begründung:** Diese Korrektur stellt die von D-102 verlangte Symmetrie der +ausgelieferten Glutrinne-Startaufstellung mit dem kleinsten Eingriff her und +bewahrt deren bestehende Layoutachse. Beide Seiten erhalten exakt dieselben +Punkt-zu-Footprint-Abstände und je 45 legale gespiegelte Folge-Raffinerieplätze, +ohne das Feldlayout oder die Ernteroute neu zu balancieren. Eine spätere +Rezentrierung auf die physische Kartenmitte wäre eine eigene Karten- und +Balanceentscheidung. + +**Konsequenzen:** Runtime, Headless-Szenario, Map-Asset, Generator und beide +Canonical-Match-Testspiegel ziehen den zweiten HQ-Ursprung beziehungsweise +Mittelpunkt atomar nach. Initialzustands-, Replay- und Ausgangshashes werden +neu gemessen; alte Starts scheitern über den `InitialStateHash` vor Tick 1. +Die gesetzten Start-HQs laufen über den dokumentierten +`PlaceCompletedBuilding`-Bypass; D-107 gleicht die D-104-Abstände und legalen +Folge-Raffinerieplätze aus, nicht die Platzierungslegalität der Start-HQs +selbst. +`RulesHash64` bleibt auf D-104-Revision V3, `DefinitionsHash64` sowie Zustands- +und Befehlsschemata bleiben unverändert. D-102 bleibt für Feldanzahl, +Koordinaten, Reserven, Reihenfolge und Ernterate vollständig verbindlich. + ## Offene Punkte - Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden. @@ -3374,6 +3509,8 @@ Konvergenz, `long.MaxValue` und die Ablehnung des alten Rules-Stubs ab. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.39.0 | 2026-08-10 | D-107 aufgenommen: Die bestehende Glutrinne-Layoutachse spiegelt Punkte um 124 und 3×3-Footprint-Ursprünge abgeleitet um 122; der zweite HQ-Ursprung wird von `(120,120)` auf `(118,118)` korrigiert | Agent (unter Delegation) / Dennis Westermann | +| 1.38.0 | 2026-08-10 | D-104 aufgenommen: footprintbasierte Chebyshev-Platzierung mit Einfluss-, Feld-, Gelände- und Gebäudeabständen, zustandslose kumulative Reparaturkosten von 30 Prozent, kanonisch dichte Reparaturreihenfolge und Rules-Revision V3 | Project Owner / Agent (unter Delegation) | | 1.37.0 | 2026-08-10 | D-103 aufgenommen: Bauvoraussetzungen werden eine fraktionsgleiche All-of-Maske über unveränderte `UnitRole`-Wirewerte; Hash-, Fail-Closed-, Plattformmodul- und KI-Handoff-Folgen festgeschrieben | Agent (unter Delegation) / Dennis Westermann | | 1.36.0 | 2026-08-10 | D-102 aufgenommen: fünf symmetrische endliche Aetheriumfelder werden geliefert, die mangels belastbarer Zielkurve unveränderte Ernterate von 2 AE/Tick wird ausdrücklich getrennt kalibriert | Project Owner / Agent | | 1.35.0 | 2026-08-10 | D-106 aufgenommen: einmalige 2.000-AE-HQ-Basis, +2.000 je fertigem Lager, sofort gedeckelte Einzahlungen, zustandsloser 25-%-Abbau des aktuellen Überhangs je Sekunde und kanonischer Rules-Hash; D-024/D-096 in Verlustausformung, HQ-Stapelung und Replay-Kompatibilität teilweise ersetzt; Fehlverweise der Lobbyfamilie D-095–D-097 auf D-092–D-094 berichtigt | Agent (unter Delegation) / Dennis Westermann | diff --git a/docs/production/DemoRunbook.md b/docs/production/DemoRunbook.md index 52e3562..a2dc1d7 100644 --- a/docs/production/DemoRunbook.md +++ b/docs/production/DemoRunbook.md @@ -145,7 +145,7 @@ steht davor und dauert so lange, wie man ihn zeigen will. 6. **Gegenwehr (6:00):** Die erste gegnerische Angriffswelle trifft ein. Infanterie per Box auswählen, mit A auf einen Angreifer klicken; Schaden und Gesundheits-Tint beobachten. -7. **Gegenstoß (9:00):** Eigene Truppe Richtung (120,120) schicken; die +7. **Gegenstoß (9:00):** Eigene Truppe Richtung (119,119) schicken; die Legion-Basis erscheint, sobald eigene Einheiten sie aufklären. **Ziel: das gegnerische Hauptquartier zerstören** — das beendet das Spiel sofort. 8. **Abschluss:** Ergebnis in der Statusleiste (VICTORY / DEFEAT); per F3 die 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 aa67fb8..64e7b48 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.5.0 | **Status:** in Umsetzung | **Verantwortungsbereich:** Netzstrang (Maintainer) | **Sprint:** 16 | **Vorgänger:** [12_Sprint_Zu_Zweit.md](12_Sprint_Zu_Zweit.md) Strang C | **Parallel zu:** [13B](13B_Sprint_Einheitenverhalten.md) | **Regelwerk:** [13-15_Parallelbetrieb.md](13-15_Parallelbetrieb.md) | **UX-Gate:** human | **Leitsatz:** ein Gebäude, das Strom zieht und nichts tut, ist kein Platzhalter, sondern ein Schaden +**Version:** 1.7.1 | **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 @@ -241,6 +241,15 @@ aktuellen Host aber vor Tick 1 mit `RulesHash64`-Mismatch abgelehnt. **Symmetrie ist Pflicht.** Beide Startpositionen müssen gleich weit zu Expansion und Zentrum liegen — sonst entscheidet die Karte das erste Mensch-gegen-Mensch-Match. +D-107 präzisiert die ausgelieferte Glutrinne-Geometrie: Die bestehende +D-102-Layoutachse läuft durch Zelle `(62,62)`. Punkte spiegeln als +`(x,y) → (124-x,124-y)`, die unteren linken Ursprünge der einheitlichen +3×3-Footprints als `(x,y) → (122-x,122-y)`. Der zweite HQ-Ursprung liegt daher +bei `(118,118)` statt `(120,120)`. Das gleicht die D-104-Abstände und legalen +Folge-Raffinerieplätze auf 45/45 aus; die gesetzten Start-HQs selbst nutzen den +dokumentierten Setup-Bypass. Die Achse ist die bestehende Glutrinne-Layoutachse, +nicht die physische Mittelpunktspiegelung des gesamten 128×128-Rasters. + Feldreserven und Feldpositionen liegen nicht in `SimDefinitions`, sondern in der kanonischen Startaufstellung. D-102 trennt diese belegte Knappheitskorrektur ausdrücklich von der noch unbelegten Ernteraten-Kalibrierung; @@ -291,28 +300,58 @@ delegiert auf dieselbe Maskenprüfung. Die ausgelieferte Skirmish-KI plante bislang Raffinerie → Kaserne. Das neue Kraftwerk-Tor wird nach D-105 durch eine begrenzte Integrationsreparatur im fremden `Scripts/AI*`-Schreibbereich bedient: r7 plant Raffinerie → Kraftwerk → -Kaserne, beide Testspiegel sichern die Reihenfolge. Der kanonische Ausgang wurde -auf dem integrierten Head mit Tick 2.705 und `0x28F2CC571BCE6B76` frisch -gemessen; die lokale Gesamtsuite ist mit 685/685 grün. Der Merge wartet danach -noch auf die grüne PR-CI. Der referenzierte externe AiLab-Journalpfad ist in +Kaserne, beide Testspiegel sichern die Reihenfolge. Auf dem integrierten +16.8-Head vor D-104/D-107 wurde der kanonische Ausgang mit Tick 2.705 und +`0x28F2CC571BCE6B76` gemessen; die damalige lokale Gesamtsuite war mit 685/685 +grün. D-104/D-107 bewegen diesen Simulationsausgang anschließend erneut, ohne +die r7-KI-Kennung zu ändern. Der referenzierte externe AiLab-Journalpfad ist in diesem Repository nicht vorhanden, daher stehen Messung und Restrisiko ehrlich in Changelog und PR statt in einem erfundenen Artefakt. ### 16.9 · Platzierungsregeln und Reparaturkosten (C6) -- **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 damit eine eigene Inhaberentscheidung. +- **Snapshot-Fortsetzung bleibt reihenfolgeidentisch.** Sites, fertige + Platzierungen und Reparaturaufträge bilden dichte, reihenfolgestabile Folgen: + Entfernen kompaktiert, neue Einträge hängen hinten an. Gespiegelte Tests + sichern Abbruch/Verkauf/Stop → Snapshot/Restore → Append sowie die + beobachtbare Reparatur-Gewinnerreihenfolge. Ein Restore entfernt außerdem + zuerst die ausschließlich im Zielhost vorhandenen dynamischen Footprints aus + dem Pfadkostenfeld, bevor er den Snapshotzustand neu aufbaut. +- **Kompatibilitätsgrenze:** D-104 hebt `RulesHash64` append-only auf Revision 3 + und bindet den 3×3-Footprint, 30-Prozent-Reparaturkosten, Einflussradius 8, + Gebäudeabstand 2 sowie Feldabstände 1/3/2. Die historischen V1-/V2-Streams + und Hashes bleiben eingefroren; V2-Replays und -Peers werden gegenüber V3 + vor Tick 1 abgelehnt. +- **Integrierter Ausgang:** Mit D-104 und der D-107-HQ-Korrektur entscheidet die + kanonische KI-Partie auf Tick 2.726 mit `0x10B83E94F86F2E55`. Die Kennung + bleibt `r7.E34435F9`: bewegt hat sich die Simulation, nicht die KI. Die + vollständige lokale .NET-Suite ist auf diesem Integrationsstand mit 706/706 + grün. ### 16.10 · Ehrliche Rückmeldung am Entscheidungspunkt (#47, #48, #45) @@ -400,9 +439,11 @@ dann **16.6**. Jeder Abwurf mit Begründung in den | D-097 | „Stoppen" löscht den Angriffsbefehl; ein Halte-Feuer bleibt beim Einheitenstrang | Inhaber | | D-102 | Fünf endliche, punktgespiegelte Aetheriumfelder; `HarvestRateAE` bleibt bis zur gespielten Kalibrierung bei 2 AE/Tick | Inhaber / Agent | | D-103 | Bauvoraussetzungen werden eine fraktionsgleiche All-of-Maske; unbekannte Bits scheitern geschlossen | Agent unter Inhaberdelegation | +| D-104 | Footprintbasierte Chebyshev-Platzierung; Reparatur kostet kumulativ 30 % des Neupreises, höchstens ein wirksamer Auftrag je Ziel und Tick | Inhaber (Zielwerte) / Agent (Ausformung) | | D-106 | AE-Kontobasis gilt einmalig je Slot; vorhandener Überhang zerfällt zustandslos pro Sekunde und die Regelrevision wird im Match-Fingerprint gebunden | Agent unter Inhaberdelegation | +| D-107 | Die bestehende Glutrinne-Layoutachse spiegelt Punkte um 124 und 3×3-Footprint-Ursprünge um 122; der zweite HQ-Ursprung wird auf `(118,118)` korrigiert | Agent unter Inhaberdelegation | -D-096, D-097, D-102, D-103 und D-106 sind im [DecisionLog](../DecisionLog.md) +D-096, D-097, D-102, D-103, D-104, D-106 und D-107 sind im [DecisionLog](../DecisionLog.md) eingetragen. D-098 (Entwurf) und D-099 stehen dort für [Sprint 17](17_Sprint_Zugangsprotokoll.md); D-092 bis D-094 gehören zu [Sprint 14](14_Sprint_Lobby.md). D-100 wird hier mangels eigenem @@ -422,14 +463,17 @@ den zutreffenden Grund. ## Versionsrelevanz `minor` — neue spielbare Fähigkeiten und Verhaltensänderungen; kein Zustands-, -Schema- oder Wireformatbruch. Die `RulesHash64`-Kompatibilitätsgrenze zwischen -Revision 1 und 2 ist beabsichtigt. Die Baseline-Neusetzung ist Zweck der Tests, -kein Bruch. +Schema- oder Wireformatbruch. Die `RulesHash64`-Kompatibilitätsgrenzen zwischen +Revision 1, 2 und 3 sind beabsichtigt. Die Baseline-Neusetzung ist Zweck der +Tests, kein Bruch. ## Änderungsverlauf | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.7.1 | 2026-08-10 | Snapshot-Restore ersetzt nun auch den dynamischen CostField-Footprintzustand eines abweichenden Zielhosts; gespiegelter Regressionstest und finaler 706/706-Nachweis ergänzt | Agent (unter Delegation) / Dennis Westermann | +| 1.7.0 | 2026-08-10 | D-107 und den integrierten 16.9-Vertrag ergänzt: Glutrinne-Layoutachse, HQ-Ursprung `(118,118)`, 45/45 Folge-Raffinerieplätze, dichte Snapshot-Reihenfolgen, Rules-Revision 3 und finaler r7-Ausgangspin | Agent (unter Delegation) / Dennis Westermann | +| 1.6.0 | 2026-08-10 | 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.5.0 | 2026-08-10 | D-103 für Paket 16.8 ergänzt: fraktionsgleiche All-of-Voraussetzungen, fail-closed Maskenvertrag, Definitions-Hash-Grenze und koordinierter KI-Handoff | Agent (unter Delegation) / Dennis Westermann | | 1.4.0 | 2026-08-10 | D-102 ergänzt: fünf endliche symmetrische Felder sind Paket 16.7; die Ernterate bleibt mangels gespielter Zielkurve ausdrücklich bei 2 AE/Tick und wird getrennt kalibriert | Project Owner / Agent | | 1.3.0 | 2026-08-10 | C4-Kompatibilitätsgrenze dokumentiert: Low-Power-Reparatur bindet Rules-Revision 2 und 10/5 HP pro Tick, ohne Zustands- oder Schema-Bump | Codex / Dennis Westermann | diff --git a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs index 16e66aa..dbdfcf9 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs @@ -43,17 +43,18 @@ namespace Nova.SimRunner.Tests [TestFixture] public sealed class CanonicalAiOutcomeTests { - /// Decided tick of the canonical AI match, last moved by: Sprint 16.8 / D-103 (r7 prerequisite handoff). Previous value: 2546 (Sprint 16.4). - private const uint PinnedDecidedTick = 2705u; + /// Decided tick of the canonical AI match, last moved by: Sprint 16.9 / D-104 and D-107 placement geometry. Previous value: 2705 (Sprint 16.8). + private const uint PinnedDecidedTick = 2726u; /// /// End-state hash of the canonical AI match, last moved by: Sprint 16 - /// package 16.8 / D-103. The all-of prerequisite contract and the r7 - /// handoff add the required Power plant before the Barracks. + /// package 16.9 / D-104 and D-107. The footprint placement rules and + /// corrected mirrored HQ geometry move the simulated match without + /// changing the AI implementation. /// AiBehaviorId is r7.E34435F9. - /// Previous value: 0xE784E6184AD16081 (Sprint 16.4). + /// Previous value: 0x28F2CC571BCE6B76 (Sprint 16.8). /// - private const string PinnedEndState = "0x28F2CC571BCE6B76"; + private const string PinnedEndState = "0x10B83E94F86F2E55"; [Test] public void CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState() diff --git a/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs b/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs index 2bf06a8..321e141 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs @@ -88,6 +88,8 @@ private struct FieldLayout // slot 0 Alliance (role value), slot 1 Legion (role value + 17). private const ushort DefHQAlliance = 3; private const ushort DefHQLegion = 20; + private const ushort DefRefineryAlliance = 4; + private const ushort DefRefineryLegion = 21; private sealed class ReferenceHost { @@ -168,7 +170,7 @@ private sealed class SlotLayout private static readonly SlotLayout Slot1Layout = new SlotLayout { - HqOriginX = 120, HqOriginY = 120, + HqOriginX = 118, HqOriginY = 118, BuilderX = 111, BuilderY = 117, }; @@ -308,6 +310,55 @@ public void ReferenceOpeningPosition_RegistersFiveFiniteFields() } } + [Test] + public void CanonicalOpeningGeometry_UsesD107PointAndFootprintMirrors() + { + Assert.That(Slot0Layout.HqOriginX + Slot1Layout.HqOriginX, Is.EqualTo(122), + "3x3 footprint origins mirror as o -> 122-o"); + Assert.That(Slot0Layout.HqOriginY + Slot1Layout.HqOriginY, Is.EqualTo(122)); + Assert.That(Slot0Layout.BuilderX + Slot1Layout.BuilderX, Is.EqualTo(124), + "point coordinates mirror as p -> 124-p"); + Assert.That(Slot0Layout.BuilderY + Slot1Layout.BuilderY, Is.EqualTo(124)); + Assert.That(FieldLayouts[0].X + FieldLayouts[1].X, Is.EqualTo(124)); + Assert.That(FieldLayouts[0].Y + FieldLayouts[1].Y, Is.EqualTo(124)); + Assert.That(FieldLayouts[2].X + FieldLayouts[3].X, Is.EqualTo(124)); + Assert.That(FieldLayouts[2].Y + FieldLayouts[3].Y, Is.EqualTo(124)); + Assert.That(FieldLayouts[4].X, Is.EqualTo(62)); + Assert.That(FieldLayouts[4].Y, Is.EqualTo(62)); + } + + [Test] + public void CanonicalOpeningGeometry_OffersEqualMirroredRefineryPlacements() + { + ReferenceHost host = BuildReferenceHost(CanonicalSeed); + ApplyOpeningPosition(host); + host.Kernel.StepTick(); // commit the two completed HQ power balances + int mismatches = 0; + int allianceApplied = 0; + int legionApplied = 0; + + for (int y = 0; y <= 122; y++) + { + for (int x = 0; x <= 122; x++) + { + CommandResultCode alliance = host.Construction.ValidatePlacement( + 0, DefRefineryAlliance, x, y); + CommandResultCode legion = host.Construction.ValidatePlacement( + 1, DefRefineryLegion, 122 - x, 122 - y); + if (alliance != legion) mismatches++; + if (alliance == CommandResultCode.Applied) allianceApplied++; + if (legion == CommandResultCode.Applied) legionApplied++; + } + } + + Assert.That(mismatches, Is.EqualTo(0), + "D-107 requires every legal or rejected refinery origin to mirror exactly"); + Assert.That(allianceApplied, Is.EqualTo(45), + "the corrected Alliance opening has 45 legal follow-up refinery origins"); + Assert.That(legionApplied, Is.EqualTo(45), + "the corrected Legion opening has the same 45 legal mirrored origins"); + } + [Test] public void ReferenceOpeningPosition_IsSeedSensitive() { diff --git a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs index e17ad10..1fd0fb6 100644 --- a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs @@ -116,10 +116,13 @@ private static EntityId PlaceActiveDefensePlatformSite(TestHost host, byte team, FactionId faction = host.Economy.GetSlotFaction(team); ushort powerDefId = SimDefinitions.ToDefinitionId(faction, UnitRole.Power); ushort platformDefId = SimDefinitions.ToDefinitionId(faction, UnitRole.DefensePlatform); - Assert.That(host.Construction.PlaceCompletedBuilding(team, powerDefId, 50, 50).IsValid, Is.True, + EntityId power = host.Construction.PlaceCompletedBuilding(team, powerDefId, originX + 6, originY); + Assert.That(power.IsValid, Is.True, "a completed Power plant unlocks the DefensePlatform site"); host.Economy.ExecuteTick(Tick.Zero); Assert.That(host.Construction.TryPlaceBuilding(team, platformDefId, originX, originY), Is.True); + Assert.That(host.Entities.DespawnUnit(power), Is.True, + "remove the fixture anchor so combat can observe only the active site"); UnitState[] units = host.Entities.RawUnits; for (int i = 0; i < host.Entities.Capacity; i++) diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index f8f2d6a..bdba8b6 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(); } @@ -221,9 +230,9 @@ public void ValidatePlacement_ForeignFactionDefinition_IsRejectedInvalidTarget() // Legion one (id 24) — a known id naming unbuildable content is an // invalid target, exactly like an unknown one. var f = new Fixture(configure: e => e.SetSlotFaction(1, FactionId.Legion)); - Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 36, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 30, 20).IsValid, Is.True, "Legion HQ prerequisite"); - 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); @@ -242,8 +251,8 @@ public void ValidatePlacement_ForeignFactionDefinition_IsRejectedInvalidTarget() public void PlaceBuilding_LegionSlot_ChargesLegionCost_AndBuildsFaster() { var f = new Fixture(configure: e => e.SetSlotFaction(1, FactionId.Legion)); - Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 36, 40).IsValid, Is.True, "Legion HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(1, 22, 40, 40).IsValid, Is.True, "Legion power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(1, 20, 30, 20).IsValid, Is.True, "Legion HQ prerequisite"); + 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(110), @@ -280,8 +289,8 @@ private static uint RawOfCompleted(Fixture f, byte slot, UnitRole role) public void PlaceBuilding_ChargesExactCost_AndCreatesSiteEntity() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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) @@ -310,6 +319,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"); @@ -337,10 +347,145 @@ 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, 3, 12, 20).IsValid, Is.True, "HQ influence and prerequisite 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, 3, 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, 3, 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, 3, 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, 3, 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, 3, 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, 3, 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, 3, 8, 20).IsValid, Is.True, "HQ influence and prerequisite anchor"); + f.Step(1); + return f; + } + [Test] public void PlaceBuilding_AllPrerequisitesAreRequired_AndUnknownBitsFailClosed() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 6, 26, 20).IsValid, Is.True, + "neutral Storage influence anchor satisfies neither Barracks prerequisite"); f.SpawnBuilder(0, 19, 20); UnitRoleMask barracksPrerequisites = UnitRoleMask.HQ | UnitRoleMask.Power; @@ -349,15 +494,15 @@ public void PlaceBuilding_AllPrerequisitesAreRequired_AndUnknownBitsFailClosed() Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet), "Barracks requires both HQ and Power"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True); Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites), Is.EqualTo(UnitRoleMask.Power), "HQ alone is insufficient"); Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet)); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 34, 20).IsValid, Is.True); Assert.That(f.Construction.GetMissingPrerequisiteRoles(0, barracksPrerequisites), Is.EqualTo(UnitRoleMask.None)); Assert.That(f.Construction.HasFinishedBuildings(0, barracksPrerequisites), Is.True); - f.Step(1); // commit the balance (130 provided) + f.Step(1); // commit the balance (130 provided, Storage draws 5) Assert.That(f.Construction.ValidatePlacement(0, 7, 20, 20), Is.EqualTo(CommandResultCode.Applied)); var onlyPower = new Fixture(); @@ -371,6 +516,8 @@ public void PlaceBuilding_AllPrerequisitesAreRequired_AndUnknownBitsFailClosed() Is.EqualTo(UnitRoleMask.HQ), "a foreign completed building does not satisfy the mask"); var unfinished = new Fixture(startingCredits: 3000); + Assert.That(unfinished.Construction.PlaceCompletedBuilding(0, 6, 26, 20).IsValid, Is.True, + "neutral influence anchor for the HQ site"); unfinished.SpawnBuilder(0, 19, 20); Assert.That(unfinished.Construction.TryPlaceBuilding(0, 3, 20, 20), Is.True, "own HQ site"); Assert.That(unfinished.Construction.GetMissingPrerequisiteRoles(0, UnitRoleMask.HQ), @@ -389,14 +536,14 @@ public void PlaceBuilding_PowerRule_RequiresSufficientFreePower() // Committed balance: HQ 30 provided, completed VehicleFactory 25 // required -> 5 free. The factory satisfies the ResearchLab's // prerequisite, so only the power gate can reject it. - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 44, 40).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 16, 20).IsValid, Is.True); f.Step(1); // let the economy recompute the balance Assert.That(f.Construction.HasFinishedBuildings(0, UnitRoleMask.VehicleFactory), Is.True); Assert.That(f.Construction.ValidatePlacement(0, 9, 20, 20), Is.EqualTo(CommandResultCode.RejectedPrerequisitesNotMet), "ResearchLab draws 30 but only 5 are free"); - Assert.That(f.Construction.ValidatePlacement(0, 5, 60, 60), Is.EqualTo(CommandResultCode.Applied), + Assert.That(f.Construction.ValidatePlacement(0, 5, 20, 20), Is.EqualTo(CommandResultCode.Applied), "power-providing buildings are exempt from the rule"); } @@ -408,7 +555,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)"); @@ -419,15 +566,15 @@ 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, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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); @@ -456,6 +603,8 @@ public void SiteProgress_LowPower_ExactlyHalvesProgress() Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 48, 40).IsValid, Is.True); Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True); Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 44, 40).IsValid, Is.True); + 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); @@ -479,14 +628,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(4)); + Assert.That(f.Construction.BuildingCount, Is.EqualTo(5)); } [Test] public void Completion_NormalizesLegacySiteRole_AndPowerAppliesFromNextTick() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ prerequisite"); + 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)); @@ -513,8 +662,8 @@ public void Completion_NormalizesLegacySiteRole_AndPowerAppliesFromNextTick() 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, 8, 44, 40).IsValid, Is.True); // VehicleFactory prerequisite + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True); // power + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 30, 20).IsValid, Is.True); // VehicleFactory prerequisite f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -542,7 +691,7 @@ public void Site_CarriesDefinitionRole_ButDrawsAndProvidesNoPower_UntilCompletio // read that role. A Refinery site drains nothing, a Power site // feeds nothing, until the site register flips at completion. var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "the completed HQ supplies the 30 power needed to permit the Refinery"); f.SpawnBuilder(0, 19, 20); f.Step(1); // commit: HQ provides 30, nothing required @@ -573,7 +722,7 @@ public void Site_CarriesDefinitionRole_ButDrawsAndProvidesNoPower_UntilCompletio public void PowerSite_ProvidesNothing_UntilCompletion() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 12, 20).IsValid, Is.True, "the completed HQ satisfies the Power-plant prerequisite and provides 30 power"); f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -608,7 +757,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 @@ -638,10 +787,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); @@ -650,7 +799,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); @@ -661,17 +810,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] @@ -680,9 +840,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); @@ -708,9 +872,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); @@ -761,8 +929,8 @@ public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged( public void CancelConstruction_Refunds75Percent_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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 @@ -785,8 +953,8 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint() public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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); @@ -808,9 +976,9 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() public void CancelConstruction_RefundIsCappedAtStorageCeiling() { var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ provides power and the 2,000 AE ceiling"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "the completed Power plant satisfies the Barracks prerequisite"); f.SpawnBuilder(0, 19, 20); f.Step(1); @@ -848,11 +1016,13 @@ public void SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay() public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, - "a completed power plant keeps the normal repair rate active"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, + "HQ keeps the starting credits inside the D-106 capacity"); 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); @@ -860,16 +1030,166 @@ 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); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 26, 20).IsValid, Is.True, + "HQ keeps the starting credits inside the D-106 capacity"); + 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); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 23, 26, 20).IsValid, Is.True, + "Storage raises capacity without providing power"); + 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); + Assert.That(fullPower.Construction.PlaceCompletedBuilding(0, 23, 30, 20).IsValid, Is.True, + "Storage keeps credits inside the D-106 capacity"); + 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] @@ -919,8 +1239,8 @@ public void Repair_Validation_RejectsNonBuilder_AndUndamagedTarget() public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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); @@ -934,11 +1254,144 @@ public void DestroyedSite_AbortsWithoutRefund_AndFreesFootprint() Assert.That(f.Construction.IsCellFree(20, 20), Is.True); } + [Test] + public void SnapshotRestore_ReplacesPriorDynamicFootprintsInCostField() + { + var source = new Fixture(); + var restored = new Fixture(); + Assert.That(source.Construction.PlaceCompletedBuilding(0, 3, 4, 4).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 3, 4, 4).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 5, 10, 4).IsValid, Is.True, + "the target host starts with an extra footprint absent from the snapshot"); + Assert.That(restored.CostField.IsWalkable(10, 4), Is.False); + + byte[] snapshotBlock = WriteConstructionState(source); + Assert.That(restored.Construction.TryRestoreState(snapshotBlock), Is.True); + + Assert.That(restored.CostField.IsWalkable(4, 4), Is.False, + "the footprint present in the snapshot must be rebuilt"); + Assert.That(restored.CostField.IsWalkable(10, 4), Is.True, + "a footprint present only in the prior live state must be removed"); + Assert.That(restored.Construction.ValidatePlacement(0, 5, 10, 4), Is.EqualTo(CommandResultCode.Applied), + "D-104 placement must not observe stale dynamic obstacles after restore"); + } + + [Test] + public void RepairOrderRemoval_SnapshotContinuation_PreservesWinnerOrder() + { + var live = new Fixture(startingCredits: 2); + var restored = new Fixture(startingCredits: 2); + + Assert.That(live.Construction.PlaceCompletedBuilding(0, 3, 4, 4).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 3, 4, 4).IsValid, Is.True); + Assert.That(live.Construction.PlaceCompletedBuilding(0, 5, 10, 4).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 5, 10, 4).IsValid, Is.True); + EntityId liveFirstTarget = live.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + EntityId restoredFirstTarget = restored.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + EntityId liveSecondTarget = live.Construction.PlaceCompletedBuilding(0, 7, 26, 20); + EntityId restoredSecondTarget = restored.Construction.PlaceCompletedBuilding(0, 7, 26, 20); + EntityId liveFirstBuilder = live.SpawnBuilder(0, 25, 20); + EntityId restoredFirstBuilder = restored.SpawnBuilder(0, 25, 20); + EntityId liveSecondBuilder = live.SpawnBuilder(0, 19, 20); + EntityId restoredSecondBuilder = restored.SpawnBuilder(0, 19, 20); + live.Entities.GetUnitRef(liveFirstTarget).CurrentHealth = 100; + restored.Entities.GetUnitRef(restoredFirstTarget).CurrentHealth = 100; + live.Entities.GetUnitRef(liveSecondTarget).CurrentHealth = 100; + restored.Entities.GetUnitRef(restoredSecondTarget).CurrentHealth = 100; + live.Step(1); + restored.Step(1); + + uint firstBuilderRaw = UnitCommandStateView.ToRawEntityId(liveFirstBuilder); + uint secondBuilderRaw = UnitCommandStateView.ToRawEntityId(liveSecondBuilder); + uint firstTargetRaw = UnitCommandStateView.ToRawEntityId(liveFirstTarget); + uint secondTargetRaw = UnitCommandStateView.ToRawEntityId(liveSecondTarget); + Assert.That(UnitCommandStateView.ToRawEntityId(restoredFirstBuilder), Is.EqualTo(firstBuilderRaw)); + Assert.That(UnitCommandStateView.ToRawEntityId(restoredSecondBuilder), Is.EqualTo(secondBuilderRaw)); + Assert.That(UnitCommandStateView.ToRawEntityId(restoredFirstTarget), Is.EqualTo(firstTargetRaw)); + Assert.That(UnitCommandStateView.ToRawEntityId(restoredSecondTarget), Is.EqualTo(secondTargetRaw)); + + live.Construction.AssignRepairOrder(firstBuilderRaw, firstTargetRaw); + live.Construction.AssignRepairOrder(secondBuilderRaw, firstTargetRaw); + restored.Construction.AssignRepairOrder(firstBuilderRaw, firstTargetRaw); + restored.Construction.AssignRepairOrder(secondBuilderRaw, firstTargetRaw); + live.Construction.ClearRepairOrder(firstBuilderRaw); + restored.Construction.ClearRepairOrder(firstBuilderRaw); + + byte[] snapshotBlock = WriteConstructionState(live); + Assert.That(restored.Construction.TryRestoreState(snapshotBlock), Is.True); + live.Construction.AssignRepairOrder(firstBuilderRaw, secondTargetRaw); + restored.Construction.AssignRepairOrder(firstBuilderRaw, secondTargetRaw); + Assert.That(WriteConstructionState(restored), Is.EqualTo(WriteConstructionState(live)), + "removal plus restore must retain the dense authoritative order before appending"); + + live.Step(1); + restored.Step(1); + Assert.That(restored.Kernel.CalculateStateHash(), Is.EqualTo(live.Kernel.CalculateStateHash())); + Assert.That(live.Entities.GetUnitRef(liveFirstTarget).CurrentHealth, Is.EqualTo(110), + "the surviving older order spends the only 2 AE first"); + Assert.That(live.Entities.GetUnitRef(liveSecondTarget).CurrentHealth, Is.EqualTo(100), + "the appended order is atomically refused after the credits are spent"); + Assert.That(restored.Entities.GetUnitRef(restoredFirstTarget).CurrentHealth, + Is.EqualTo(live.Entities.GetUnitRef(liveFirstTarget).CurrentHealth)); + Assert.That(restored.Entities.GetUnitRef(restoredSecondTarget).CurrentHealth, + Is.EqualTo(live.Entities.GetUnitRef(liveSecondTarget).CurrentHealth)); + } + + [Test] + public void SiteRemoval_SnapshotContinuation_PreservesAppendOrder() + { + var live = new Fixture(startingCredits: 5000); + var restored = new Fixture(startingCredits: 5000); + Assert.That(live.Construction.PlaceCompletedBuilding(0, 3, 20, 20).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 3, 20, 20).IsValid, Is.True); + Assert.That(live.Construction.TryPlaceBuilding(0, 5, 26, 20), Is.True); + Assert.That(restored.Construction.TryPlaceBuilding(0, 5, 26, 20), Is.True); + uint liveFirstSite = UnitCommandStateView.ToRawEntityId(SiteEntity(live)); + uint restoredFirstSite = UnitCommandStateView.ToRawEntityId(SiteEntity(restored)); + Assert.That(live.Construction.TryPlaceBuilding(0, 5, 20, 26), Is.True); + Assert.That(restored.Construction.TryPlaceBuilding(0, 5, 20, 26), Is.True); + Assert.That(live.Construction.CancelConstruction(liveFirstSite), Is.True); + Assert.That(restored.Construction.CancelConstruction(restoredFirstSite), Is.True); + + byte[] snapshotBlock = WriteConstructionState(live); + Assert.That(restored.Construction.TryRestoreState(snapshotBlock), Is.True); + Assert.That(live.Construction.TryPlaceBuilding(0, 5, 26, 26), Is.True); + Assert.That(restored.Construction.TryPlaceBuilding(0, 5, 26, 26), Is.True); + + Assert.That(WriteConstructionState(restored), Is.EqualTo(WriteConstructionState(live)), + "a new site must append after the surviving site on both live and restored hosts"); + Assert.That(restored.Kernel.CalculateStateHash(), Is.EqualTo(live.Kernel.CalculateStateHash())); + } + + [Test] + public void BuildingRemoval_SnapshotContinuation_PreservesAppendOrder() + { + var live = new Fixture(); + var restored = new Fixture(); + EntityId liveFirst = live.Construction.PlaceCompletedBuilding(0, 3, 10, 20); + EntityId restoredFirst = restored.Construction.PlaceCompletedBuilding(0, 3, 10, 20); + Assert.That(liveFirst.IsValid, Is.True); + Assert.That(restoredFirst.IsValid, Is.True); + Assert.That(live.Construction.PlaceCompletedBuilding(0, 3, 20, 20).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 3, 20, 20).IsValid, Is.True); + Assert.That(live.Construction.SellBuilding(UnitCommandStateView.ToRawEntityId(liveFirst)), Is.True); + Assert.That(restored.Construction.SellBuilding(UnitCommandStateView.ToRawEntityId(restoredFirst)), Is.True); + + byte[] snapshotBlock = WriteConstructionState(live); + Assert.That(restored.Construction.TryRestoreState(snapshotBlock), Is.True); + Assert.That(live.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True); + Assert.That(restored.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True); + + Assert.That(WriteConstructionState(restored), Is.EqualTo(WriteConstructionState(live)), + "a new completed placement must append after the survivor on both hosts"); + Assert.That(restored.Kernel.CalculateStateHash(), Is.EqualTo(live.Kernel.CalculateStateHash())); + } + [Test] public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ prerequisite"); + 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); @@ -974,8 +1427,8 @@ public void Snapshot_Roundtrip_IsByteIdentical_AndTamperingIsRejected() public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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), @@ -1012,8 +1465,8 @@ public void Snapshot_AssignedBuilderRoleViolation_IsRejectedWithoutMutation() public void ProgressSites_ReassignsNonBuilderAssignment_DefenseInDepth() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 36, 40).IsValid, Is.True, "HQ prerequisite"); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + 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); @@ -1050,5 +1503,12 @@ private static EntityId SiteEntity(Fixture f) } throw new System.InvalidOperationException("no site entity found"); } + + private static byte[] WriteConstructionState(Fixture fixture) + { + var writer = new SnapshotBlockWriter(); + fixture.Construction.WriteState(writer); + return writer.ToArray(); + } } } diff --git a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs index c38f81b..747aa9a 100644 --- a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs @@ -176,8 +176,10 @@ public void HarvestThenReturn_ThroughSealedCommands_RaisesCreditsExactly() public void ReturnCargo_HoldsAtRefinerySite_ThenDepositsAtCompletedRefinery() { var host = EcoHost.Create(Seed); - Assert.That(host.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + Assert.That(host.Construction.PlaceCompletedBuilding(0, 3, 2, 10).IsValid, Is.True, "the completed HQ supplies the placement power budget"); + Assert.That(host.Economy.TryAddField(63, new GridPos2D(10, 14), 9000), Is.True, + "a Refinery needs a registered field at footprint distance 1 through 3"); host.StepTick(); Assert.That(host.Construction.TryPlaceBuilding(0, 4, 10, 10), Is.True, "the nearby definition-role Refinery is still only a site"); diff --git a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs index 51764b7..0be4960 100644 --- a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs @@ -504,9 +504,9 @@ public void CapacityFor_CountsCompletedStorage_AndExcludesSites() kernel.RegisterSystem(economy); kernel.Start(); Assert.That(construction.PlaceCompletedBuilding( - 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 12, 20).IsValid, Is.True); Assert.That(construction.PlaceCompletedBuilding( - 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 36, 40).IsValid, Is.True, + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 16, 20).IsValid, Is.True, "the completed Refinery satisfies the Storage prerequisite"); kernel.StepTick(); // commit the grid (30 provided) for the placement power rule @@ -547,6 +547,9 @@ public void CapacityFor_HqSiteAlone_ProvidesNoAccountBase() var economy = new EconomySystem(entities, startingCredits: 3000); var construction = new ConstructionSystem(entities, economy); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Power), 12, 20).IsValid, Is.True, + "a completed Power plant supplies D-104 influence without adding account capacity"); Assert.That(construction.TryPlaceBuilding( 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 20, 20), Is.True); Assert.That(economy.CapacityFor(0), Is.EqualTo(0L), diff --git a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs index 6892fc1..f63285d 100644 --- a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs +++ b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs @@ -373,8 +373,8 @@ public static ClientHost Create(RelayMatchClient client) for (byte slot = 0; slot < 2; slot++) { ushort fieldId = (ushort)(slot + 1); - int fieldCell = slot == 0 ? 7 : 119; - int hqOrigin = slot == 0 ? 4 : 120; + int fieldCell = slot == 0 ? 7 : 117; + int hqOrigin = slot == 0 ? 4 : 118; Assert.That(economy.TryAddField(fieldId, new GridPos2D(fieldCell, fieldCell), 9000), Is.True); FactionId faction = economy.GetSlotFaction(slot); EntityId hq = construction.PlaceCompletedBuilding( @@ -382,11 +382,11 @@ public static ClientHost Create(RelayMatchClient client) Assert.That(hq.IsValid, Is.True); SimDefinitions.TryGetUnit(faction, UnitRole.Builder, out SimUnitDefinition builderDef); EntityId builder = entities.SpawnUnit(slot, - new Transform2D(SimFixed.FromInt(slot == 0 ? 13 : 113), SimFixed.FromInt(slot == 0 ? 7 : 119)), + new Transform2D(SimFixed.FromInt(slot == 0 ? 13 : 111), SimFixed.FromInt(slot == 0 ? 7 : 117)), builderDef.MoveSpeed, maxHealth: builderDef.MaxHealth, role: UnitRole.Builder); SimDefinitions.TryGetUnit(faction, UnitRole.BasicInfantry, out SimUnitDefinition infantryDef); EntityId infantry = entities.SpawnUnit(slot, - new Transform2D(SimFixed.FromInt(slot == 0 ? 10 : 110), SimFixed.FromInt(slot == 0 ? 10 : 110)), + new Transform2D(SimFixed.FromInt(slot == 0 ? 10 : 114), SimFixed.FromInt(slot == 0 ? 10 : 114)), infantryDef.MoveSpeed, maxHealth: infantryDef.MaxHealth, role: UnitRole.BasicInfantry); if (slot == client.AssignedSlot) { @@ -474,12 +474,12 @@ public void RunScript() if (tick == 10) { SubmitIntent(new MovePayload(new[] { BuilderRaw }, - SimFixed.FromInt(slot == 0 ? 10 : 117), SimFixed.FromInt(slot == 0 ? 5 : 117))); + SimFixed.FromInt(slot == 0 ? 10 : 114), SimFixed.FromInt(slot == 0 ? 5 : 119))); } if (tick == 40) { SubmitIntent(new PlaceBuildingPayload(refineryDef, - (ushort)(slot == 0 ? 7 : 118), (ushort)(slot == 0 ? 4 : 116))); + (ushort)(slot == 0 ? 8 : 114), (ushort)(slot == 0 ? 4 : 118))); } // The infantry marches at the enemy base: auto-acquisition // (D-087) turns this into real combat ticks on the wire. @@ -1208,6 +1208,39 @@ public void RevisionOneRulesFingerprint_RefusesTheMatchBeforeRunning() server.Stop(); } + [Test] + public void RevisionTwoRulesFingerprint_RefusesTheMatchBeforeRunning() + { + var server = new RelayServerCore(Token, Seed, Delay, string.Empty, _ => { }); + server.Start(0); + var clientA = new RelayMatchClient(); + var clientB = new RelayMatchClient(); + clientA.Connect("127.0.0.1", server.Port, Token); + clientB.Connect("127.0.0.1", server.Port, Token); + PumpUntil(server, clientA, clientB, () => clientA.HasOffer && clientB.HasOffer, "offers"); + + ClientHost hostA = ClientHost.Create(clientA); + ClientHost hostB = ClientHost.Create(clientB); + MatchFingerprint current = hostA.CreateFingerprint(); + MatchFingerprint revisionTwo = MatchFingerprint.CreateCurrent( + MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2), + current.DefinitionsHash64, current.MapHash64, + current.GetSlotOccupancyCopy(), current.GetSlotFactionCopy(), + current.StartSeed, current.InitialStateHash, current.InputDelayTicks); + + clientA.SubmitLocalProof(current.Serialize(), hostA.Kernel.SaveSnapshot()); + clientB.SubmitLocalProof(revisionTwo.Serialize(), hostB.Kernel.SaveSnapshot()); + PumpUntil(server, clientA, clientB, + () => clientA.Phase == RelayClientPhase.Ended && clientB.Phase == RelayClientPhase.Ended, + "the relay refused the revision-2/current rules mismatch"); + + Assert.That(clientA.Phase, Is.Not.EqualTo(RelayClientPhase.Running)); + Assert.That(clientB.Phase, Is.Not.EqualTo(RelayClientPhase.Running)); + Assert.That(clientA.RejectReason, Does.Contain("RulesHash64")); + Assert.That(clientB.RejectReason, Does.Contain("RulesHash64")); + server.Stop(); + } + [Test] public void SidecarFingerprintMismatch_IsFoundByTheCentralComparator() { diff --git a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs index aa5b6ad..1394aeb 100644 --- a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs +++ b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs @@ -80,15 +80,26 @@ public void ComputeHash_IsStableAcrossInstances_AndStubHashesAreDistinct() } [Test] - public void CurrentRulesHash_MovesPastRevisionOne_ForLowPowerRepair() + public void RulesRevisionOneAndTwo_GoldenHashesRemainByteStable() { ulong revisionOne = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV1); + ulong revisionTwo = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2); + + Assert.That(revisionOne, Is.EqualTo(0x531CE8F614A16CB5UL), "revision 1 canonical stream is frozen"); + Assert.That(revisionTwo, Is.EqualTo(0x07725EA26668C9F8UL), "revision 2 canonical stream is frozen"); + } + + [Test] + public void CurrentRulesHash_MovesPastRevisionTwo_ForD104PlacementAndRepair() + { + ulong revisionTwo = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2); ulong current = MatchFingerprint.ComputeCurrentRulesHash64(); - Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV2)); - Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2))); - Assert.That(current, Is.Not.EqualTo(revisionOne), - "Sprint-16.6 C4 repair behavior must not share D-106's revision-1 rules identity"); + Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV3)); + Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3))); + Assert.That(current, Is.EqualTo(0x05CCA8475789AD4AUL), "revision 3 canonical stream is frozen"); + Assert.That(current, Is.Not.EqualTo(revisionTwo), + "D-104 placement and repair behavior must not share revision 2's rules identity"); } [Test] diff --git a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs index 1ed919f..45c59aa 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/ReplayTests.cs b/tools/Nova.SimRunner.Tests/ReplayTests.cs index e5cd160..a45766a 100644 --- a/tools/Nova.SimRunner.Tests/ReplayTests.cs +++ b/tools/Nova.SimRunner.Tests/ReplayTests.cs @@ -223,6 +223,29 @@ public void FingerprintMismatch_RevisionOneRules_RefusesPlaybackBeforeTickOne() "revision-1 rules must be refused before execution"); } + [Test] + public void FingerprintMismatch_RevisionTwoRules_RefusesPlaybackBeforeTickOne() + { + ReplayTestUtil.LiveMatch live = ReplayTestUtil.RunLiveMatch(); + MatchFingerprint revisionTwo = MatchFingerprint.CreateCurrent( + MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2), + live.Fingerprint.DefinitionsHash64, live.Fingerprint.MapHash64, + live.Fingerprint.GetSlotOccupancyCopy(), live.Fingerprint.GetSlotFactionCopy(), + live.Fingerprint.StartSeed, live.Fingerprint.InitialStateHash, + live.Fingerprint.InputDelayTicks); + + ReplayTestUtil.TestHost playback = ReplayTestUtil.CreatePlaybackHost(); + Assert.That( + ReplayPlayer.TryPlay( + live.ReplayBytes, revisionTwo, playback.Kernel, playback.Ingress, + out ReplayPlaybackError error, out string detail), + Is.False); + Assert.That(error, Is.EqualTo(ReplayPlaybackError.FingerprintMismatch)); + Assert.That(detail, Does.Contain("RulesHash64")); + Assert.That(playback.Kernel.CurrentTick.Value, Is.EqualTo(0u), + "revision-2 rules must be refused before execution"); + } + [Test] public void FingerprintMismatch_DifferentSlotOccupancy_RefusesPlayback() { diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs index 7bd448b..2b21f81 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs @@ -51,7 +51,7 @@ public sealed class SkirmishAiTests /// /// End-to-end tick budget: this suite's deterministic match decides - /// at tick 2705, so 6.000 ticks is a ~2.2x margin — comfortably sane, + /// at tick 2726, so 6.000 ticks is a ~2.2x margin — comfortably sane, /// and exact because the whole loop is deterministic. /// internal const int EndToEndBudgetTicks = 6000; @@ -231,10 +231,10 @@ private static void ApplyOpeningPosition(AiHost host) for (byte slot = 0; slot < 2; slot++) { ushort fieldId = (ushort)(slot + 1); - int fieldCell = slot == HumanSlot ? 7 : 119; - int hqOrigin = slot == HumanSlot ? 4 : 120; - int builderX = slot == HumanSlot ? 13 : 113; - int builderY = slot == HumanSlot ? 7 : 119; + int fieldCell = slot == HumanSlot ? 7 : 117; + int hqOrigin = slot == HumanSlot ? 4 : 118; + int builderX = slot == HumanSlot ? 13 : 111; + int builderY = slot == HumanSlot ? 7 : 117; Assert.That(host.Economy.TryAddField(fieldId, new GridPos2D(fieldCell, fieldCell), FieldReserveAE), Is.True, $"field {fieldId} could not be registered"); @@ -415,7 +415,7 @@ public void SkirmishAi_ProducesInfantry_AndMarchesPastMidMap() Assert.That(CountUnits(host, AiSlot, UnitRole.BasicInfantry), Is.GreaterThanOrEqualTo(6), "the Barracks keeps infantry queued — the attack threshold of the profile must be reachable"); Assert.That(MinCombatCellX(host, AiSlot), Is.LessThan(64), - "at the squad threshold the army marches toward the enemy start area (slot 1 starts at x ~ 113-122)"); + "at the squad threshold the army marches toward the enemy start area (slot 1 starts at x ~ 111-120)"); } // ---------------------------------------------------------------- @@ -1222,6 +1222,24 @@ private static EntityId PlaceEnemySiteNear(AiHost host, UnitRole role, int centr { FactionId faction = host.Economy.GetSlotFaction(HumanSlot); ushort definitionId = SimDefinitions.ToDefinitionId(faction, role); + ushort powerDefinitionId = SimDefinitions.ToDefinitionId(faction, UnitRole.Power); + + // D-104 requires a nearby tracked completed HQ/Storage/Power + // footprint. Keep this focused fixture independent of the live + // base by adding a temporary Power anchor outside the site ring, + // then despawn it as soon as the site exists. + int maxOrigin = ConstructionSystem.GridSize - SimDefinitions.BuildingFootprintCells; + int[,] anchorOffsets = { { 8, 0 }, { -10, 0 }, { 0, 8 }, { 0, -10 } }; + EntityId influenceAnchor = EntityId.Invalid; + for (int i = 0; i < anchorOffsets.GetLength(0); i++) + { + int anchorX = System.Math.Max(0, System.Math.Min(maxOrigin, centreX + anchorOffsets[i, 0])); + int anchorY = System.Math.Max(0, System.Math.Min(maxOrigin, centreY + anchorOffsets[i, 1])); + influenceAnchor = host.Construction.PlaceCompletedBuilding( + HumanSlot, powerDefinitionId, anchorX, anchorY); + if (influenceAnchor.IsValid) break; + } + Assert.That(influenceAnchor.IsValid, Is.True, "a temporary D-104 influence anchor must fit near the site search"); for (int radius = 0; radius <= 6; radius++) { @@ -1243,6 +1261,7 @@ private static EntityId PlaceEnemySiteNear(AiHost host, UnitRole role, int centr if (unit.IsActive && unit.PlayerId == HumanSlot && unit.Role == role && host.Construction.IsActiveSite(unit.Id)) { + Assert.That(host.Entities.DespawnUnit(influenceAnchor), Is.True); return unit.Id; } } @@ -1250,6 +1269,7 @@ private static EntityId PlaceEnemySiteNear(AiHost host, UnitRole role, int centr } } + host.Entities.DespawnUnit(influenceAnchor); Assert.Fail($"no legal {role} site near ({centreX},{centreY})"); return EntityId.Invalid; } diff --git a/tools/Nova.SimRunner.Tests/VictorySystemTests.cs b/tools/Nova.SimRunner.Tests/VictorySystemTests.cs index 5f9ecb6..164a172 100644 --- a/tools/Nova.SimRunner.Tests/VictorySystemTests.cs +++ b/tools/Nova.SimRunner.Tests/VictorySystemTests.cs @@ -244,7 +244,8 @@ public void HqSite_DoesNotSaveTheSlot_FromTheHqLossElimination() // register is excluded from the HQ scan, exactly like the generic // role was before. TestHost host = NewHost(startingCredits: 6000); - host.SpawnUnit(0, 10, 10, UnitRole.HQ); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 3, 10, 10).IsValid, Is.True, + "the real HQ must be a tracked completed placement for D-104 influence"); host.SpawnUnit(0, 16, 10, UnitRole.Builder); host.SpawnUnit(1, 50, 50, UnitRole.HQ); host.SpawnUnit(1, 52, 50); @@ -253,7 +254,7 @@ public void HqSite_DoesNotSaveTheSlot_FromTheHqLossElimination() // Slot 0 starts a second HQ as a SITE — definition role HQ since // 16.3, 1 HP, never completed in this test (the builder stands // out of reach, so the site pauses). - Assert.That(host.Construction.TryPlaceBuilding(0, 3, 30, 30), Is.True, "HQ def 3 (Alliance)"); + Assert.That(host.Construction.TryPlaceBuilding(0, 3, 18, 10), Is.True, "HQ def 3 (Alliance)"); Assert.That(host.Construction.SiteCount, Is.EqualTo(1)); // The real HQ falls: the D-077 elimination must fire despite the @@ -404,8 +405,9 @@ public void ConstructionSite_CountsAsBuilding_AndKeepsTheSideAlive() // Slot 0 gets a real DefensePlatform site: power provider + builder // + credits are the placement prerequisites. It deliberately has // no HQ, so D-077's separate last-HQ defeat trigger cannot mask - // the D-056 site-counting behavior under test. - EntityId power = host.Construction.PlaceCompletedBuilding(0, DefPower, 40, 40); + // the D-056 site-counting behavior under test. The provider stays + // within D-104 construction influence of the site. + 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/tools/Nova.SimRunner/Determinism10000Scenario.cs b/tools/Nova.SimRunner/Determinism10000Scenario.cs index a8fbdf7..3956e2d 100644 --- a/tools/Nova.SimRunner/Determinism10000Scenario.cs +++ b/tools/Nova.SimRunner/Determinism10000Scenario.cs @@ -197,7 +197,7 @@ private struct FieldLayout /// The five canonical fields (16.7, C1 — MVPContentManifest section 5): /// two start fields and two natural expansions at 9.000 AE each, one /// contested centre at 15.000. Every slot-1 coordinate is the point - /// mirror of slot 0 through the map centre ((x, y) -> + /// mirror of slot 0 across the D-102/D-107 Glutrinne layout axis ((x, y) -> /// (124 - x, 124 - y)). Registration in ascending id order is part of /// the canonical initial state. /// @@ -625,11 +625,11 @@ private static void IssueSlotCommands( RefineryRallyX = 7, RefineryRallyY = 6, }; - /// Slot 1 base layout (top-right), the exact centre mirror of slot 0. + /// Slot 1 base layout (top-right), the exact D-107 point/footprint mirror of slot 0. private static readonly SlotLayout Slot1Layout = new SlotLayout { FieldId = 2, FieldX = 117, FieldY = 117, - HqOriginX = 120, HqOriginY = 120, + HqOriginX = 118, HqOriginY = 118, BuilderSpawnX = 111, BuilderSpawnY = 117, RefineryOriginX = 114, RefineryOriginY = 118, RefineryBuildX = 114, RefineryBuildY = 117, RefineryRallyX = 117, RefineryRallyY = 118,