diff --git a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs index b96676e..94fa074 100644 --- a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs @@ -670,7 +670,7 @@ public MatchFingerprint CreateFingerprint() slots[HumanSlot] = (byte)PlayerSlotOccupancy.Human; slots[AiSlot] = (byte)PlayerSlotOccupancy.AI; return MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots, diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index 3c0c95e..31c597a 100644 --- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs @@ -696,7 +696,8 @@ public void PowerSite_ProvidesNothing_UntilCompletion() public void CancelConstruction_Refunds75Percent_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and 2,000 AE capacity"); f.SpawnBuilder(0, 19, 20); f.Step(1); // commit the balance Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 500 spent @@ -719,7 +720,8 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint() public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and 2,000 AE capacity"); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); @@ -730,13 +732,51 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() Assert.That(f.Construction.IsCellFree(20, 20), Is.True); f.SpawnBuilder(0, 19, 20); - f.Step(1); // commit the balance (100 provided, 0 required) + f.Step(1); // commit the balance (30 provided, 0 required) Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); Assert.That(f.Construction.ValidateSell(0, siteRaw), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), "a site is cancelled, not sold"); } + [Test] + public void CancelConstruction_RefundIsCappedAtStorageCeiling() + { + var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and the 2,000 AE ceiling"); + f.SpawnBuilder(0, 19, 20); + f.Step(1); + Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 2.000 - 500 = 1.500 + uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); + f.Economy.GetPlayerEconomy(0).AddCredits(495); // raw fixture setup: 1.995 + + Assert.That(f.Construction.CancelConstruction(siteRaw), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 375 AE refund fit; the overflow is forfeit"); + } + + [Test] + public void SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay() + { + var f = new Fixture(startingCredits: 3900); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); + EntityId storage = f.Construction.PlaceCompletedBuilding(0, 6, 20, 20); + Assert.That(f.Economy.CapacityFor(0), + Is.EqualTo(EconomySystem.HqBaseCapacityAE + EconomySystem.StorageCapacityBonusAE)); + + Assert.That(f.Construction.SellBuilding(UnitCommandStateView.ToRawEntityId(storage)), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(4000L), + "only 100 of the 150 AE sale refund fit before the Storage leaves the stock"); + Assert.That(f.Economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "selling the Storage immediately lowers the derived ceiling"); + + f.Step(EconomySystem.ExcessDecayIntervalTicks); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3500L), + "tick 10 removes 25% of the 2,000 AE excess"); + } + [Test] public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { diff --git a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs index 4c8b3c0..e9e7a63 100644 --- a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs @@ -120,6 +120,11 @@ public void RestoreSessionTick() new Transform2D(SimFixed.FromInt(11), SimFixed.FromInt(10)), SimFixed.Zero, role: UnitRole.Refinery); + Entities.SpawnUnit( + owner, + new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), + SimFixed.Zero, + role: UnitRole.HQ); return (UnitCommandStateView.ToRawEntityId(harvester), harvester); } @@ -318,7 +323,7 @@ public void Replay_HarvestAndReturnIntents_PlaybackReproducesEndHash() slots[0] = (byte)PlayerSlotOccupancy.Human; slots[1] = (byte)PlayerSlotOccupancy.AI; MatchFingerprint fingerprint = MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots, @@ -483,7 +488,7 @@ public void Replay_WithHarvestRejections_PlaybackReproducesResultsAndEndHash() slots[0] = (byte)PlayerSlotOccupancy.Human; slots[1] = (byte)PlayerSlotOccupancy.AI; MatchFingerprint fingerprint = MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots, diff --git a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs index ad8650e..f793316 100644 --- a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs @@ -163,6 +163,11 @@ public void HarvestCycle_GathersExactRate_AndDepositRaisesCreditsExactly() kernel.Start(); Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + // 16.4: deposits obey the derived storage ceiling — a completed + // HQ provides the 2.000 AE base. Far away, so no reach rule here + // is touched. + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, role: UnitRole.HQ); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); entities.GetUnitRef(harvester).HarvestFieldId = 1; @@ -186,6 +191,33 @@ public void HarvestCycle_GathersExactRate_AndDepositRaisesCreditsExactly() "credits rise by exactly the cargo"); } + [Test] + public void HarvesterDeposit_OverflowIsForfeitAtTheStorageCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: 1995); + kernel.RegisterSystem(economy); + kernel.Start(); + + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, + role: UnitRole.HQ); + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(11), SimFixed.FromInt(10)), SimFixed.Zero, + role: UnitRole.Refinery); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); + ref UnitState unit = ref entities.GetUnitRef(harvester); + unit.CargoAE = 10; + unit.IsReturningCargo = true; + + kernel.StepTick(); + + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 10 AE cargo fit below the HQ ceiling"); + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(0), + "overflow is forfeit, so the full cargo leaves the Harvester"); + Assert.That(entities.GetUnitRef(harvester).IsReturningCargo, Is.False); + } + [Test] public void ReturnOrder_RefineryFootprintEdgeInReach_DepositsWithCentreTwoCellsAway() { @@ -208,6 +240,11 @@ public void ReturnOrder_RefineryFootprintEdgeInReach_DepositsWithCentreTwoCellsA 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 8, 4); Assert.That(refinery.IsValid, Is.True); + // 16.4: the deposit obeys the derived ceiling — completed HQ, + // far away so no reach rule here is touched. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + // Adjacent to the footprint's west edge cell (8,6), Chebyshev 2 // from the centre (9,5). EntityId harvester = SpawnHarvester(entities, 0, 7, 6); @@ -239,6 +276,10 @@ public void AutoCycle_CanonicalOpeningDistances_CompletesRoundTripAndResumes() Assert.That(economy.TryAddField(1, new GridPos2D(7, 7), 9000), Is.True); construction.PlaceCompletedBuilding( 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 8, 4); + // 16.4: deposits obey the derived ceiling — completed HQ, far + // away so the opening geometry under test is untouched. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); EntityId harvester = SpawnHarvester(entities, 0, 7, 6); entities.GetUnitRef(harvester).HarvestFieldId = 1; @@ -426,6 +467,200 @@ public void TryAddField_ValidatesIdentityAndReserve() Assert.That(economy.FieldCount, Is.EqualTo(1)); } + // ------------------------------------------------------------------ + // 16.4 (#53, D-024/D-096/D-106): the derived AE ceiling + // ------------------------------------------------------------------ + + [Test] + public void DepositCapped_ClampsAtTheDerivedCeiling_OverflowIsForfeit() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), "one completed HQ: the 2.000 AE base"); + + Assert.That(economy.DepositCapped(0, 1500), Is.EqualTo(1000L), + "only what fits under the ceiling lands"); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2000L), + "1000 start + 1000 that fit — the remaining 500 are forfeit"); + Assert.That(economy.DepositCapped(0, 500), Is.EqualTo(0L), "at the ceiling nothing more lands"); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2000L)); + Assert.That(economy.CapacityFor(1), Is.EqualTo(0L), "no buildings, no ceiling — the other slot is unaffected"); + } + + [Test] + public void CapacityFor_CountsCompletedStorage_AndExcludesSites() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: 3000); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + kernel.StepTick(); // commit the grid (30 provided) for the placement power rule + + // A storage SITE holds nothing yet. + Assert.That(construction.TryPlaceBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 20, 20), Is.True, + "storage site placed (cost fits the 3.000 start)"); + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "an unfinished silo holds nothing"); + + // A COMPLETED storage adds its 2.000. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 50, 50).IsValid, Is.True); + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE + EconomySystem.StorageCapacityBonusAE), + "HQ base + one completed storage"); + } + + [Test] + public void CapacityFor_MultipleCompletedHqs_ProvideOneAccountBase() + { + EntityManager entities = CreateEntities(); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 20, 20).IsValid, Is.True); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 50, 50).IsValid, Is.True); + + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "the HQ capacity is one account base, not a bonus per HQ"); + } + + [Test] + public void CapacityFor_HqSiteAlone_ProvidesNoAccountBase() + { + EntityManager entities = CreateEntities(); + var economy = new EconomySystem(entities, startingCredits: 3000); + var construction = new ConstructionSystem(entities, economy); + + Assert.That(construction.TryPlaceBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 20, 20), Is.True); + Assert.That(economy.CapacityFor(0), Is.EqualTo(0L), + "an unfinished HQ-role site is not a completed HQ"); + } + + [Test] + public void CapacityAndDeposit_InvalidSlot_ReturnZeroWithoutMutation() + { + var economy = new EconomySystem(CreateEntities()); + + Assert.That(economy.CapacityFor(byte.MaxValue), Is.EqualTo(0L)); + Assert.That(economy.DepositCapped(byte.MaxValue, 500L), Is.EqualTo(0L)); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1000L)); + } + + [Test] + public void DecayExcessBalance_QuarterPerSecond_ConvergesToTheCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + economy.GetPlayerEconomy(0).AddCredits(2000); // raw write: 3.000 total, 1.000 over the 2.000 ceiling + for (int i = 0; i < 9; i++) kernel.StepTick(); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3000L), + "no decay between the per-second decay ticks"); + + kernel.StepTick(); // tick 10: first decay — 25% of the 1.000 excess + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2750L)); + + for (int i = 0; i < 10; i++) kernel.StepTick(); // tick 20: 25% of 750 (floor 187) + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2563L), + "integer floor decay, once per second"); + + for (int i = 0; i < 80; i++) kernel.StepTick(); // tick 100: converging, minimum-1-AE steps + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2058L)); + } + + [Test] + public void DecayExcessBalance_NeverTouchesBalancesAtOrBelowTheCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + for (int i = 0; i < 25; i++) kernel.StepTick(); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1000L), + "1.000 under the 2.000 ceiling: the decay never runs"); + + // Without any building the ceiling is zero and even the start + // stock decays — the no-HQ path defined by D-106. + var lone = new EconomySystem(CreateEntities()); + var loneKernel = new SimulationKernel(new SimRandom(42UL)); + loneKernel.RegisterSystem(lone); + loneKernel.Start(); + Assert.That(lone.DepositCapped(0, 500), Is.EqualTo(0L), "no ceiling, no deposit"); + for (int i = 0; i < 10; i++) loneKernel.StepTick(); + Assert.That(lone.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(750L), + "no HQ and no storage: the 1.000 start decays (excess 1.000 over ceiling 0)"); + } + + [Test] + public void DestroyedStorage_LowersCapacity_AndStartsExcessDecay() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: 3900); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + EntityId storage = construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 20, 20); + Assert.That(storage.IsValid, Is.True); + Assert.That(economy.CapacityFor(0), + Is.EqualTo(EconomySystem.HqBaseCapacityAE + EconomySystem.StorageCapacityBonusAE)); + + Assert.That(entities.DespawnUnit(storage), Is.True, "combat destruction despawns the Storage entity"); + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE)); + for (int i = 0; i < EconomySystem.ExcessDecayIntervalTicks; i++) kernel.StepTick(); + + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3425L), + "25% of the new 1.900 AE excess decays at tick 10"); + } + + [Test] + public void DecayExcessBalance_LongMaxValue_DoesNotOverflow() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: long.MaxValue); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + for (int i = 0; i < 10; i++) kernel.StepTick(); + + long excess = long.MaxValue - EconomySystem.HqBaseCapacityAE; + long expectedLoss = excess / 4L; + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(long.MaxValue - expectedLoss)); + } + private static byte[] SerializeBlock(EconomySystem economy) { var writer = new SnapshotBlockWriter(); diff --git a/Assets/Tests/EditMode/Simulation/HarvesterAutoCycleTests.cs b/Assets/Tests/EditMode/Simulation/HarvesterAutoCycleTests.cs index a77f4fd..16f3b3f 100644 --- a/Assets/Tests/EditMode/Simulation/HarvesterAutoCycleTests.cs +++ b/Assets/Tests/EditMode/Simulation/HarvesterAutoCycleTests.cs @@ -22,7 +22,16 @@ namespace Nova.Simulation.Tests [TestFixture] public class HarvesterAutoCycleTests { - private static EntityManager CreateEntities() => new EntityManager(64); + private static EntityManager CreateEntities() + { + var entities = new EntityManager(64); + entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), + SimFixed.Zero, + role: UnitRole.HQ); + return entities; + } private static EntityId SpawnHarvester(EntityManager entities, byte player, int x, int y) { diff --git a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs index 3a73d67..b1f4687 100644 --- a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs +++ b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs @@ -18,7 +18,7 @@ public class MatchFingerprintV1Tests private static MatchFingerprint CreateStandard() { return MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), ReplayV1TestUtil.StandardSlots(), @@ -66,14 +66,16 @@ public void ComputeHash_IsStableAcrossInstances_AndStubHashesAreDistinct() Assert.AreEqual(CreateStandard().ComputeHash(), CreateStandard().ComputeHash(), "identical fingerprints must hash identically"); - ulong rules = MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules); + ulong rules = MatchFingerprint.ComputeCurrentRulesHash64(); ulong definitions = MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions); ulong map = MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map); Assert.AreNotEqual(rules, definitions); Assert.AreNotEqual(rules, map); Assert.AreNotEqual(definitions, map); - Assert.AreEqual(rules, MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), - "stub hashes must be deterministic"); + Assert.AreEqual(rules, MatchFingerprint.ComputeCurrentRulesHash64(), + "the current rules hash must be deterministic"); + Assert.AreNotEqual(rules, MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + "D-106 rules must not match the legacy empty rules stub"); } [Test] diff --git a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs index be64315..3d8a584 100644 --- a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs @@ -470,7 +470,7 @@ public void Replay_ConstructionAndProductionIntents_PlaybackReproducesEndHash() slots[0] = (byte)PlayerSlotOccupancy.Human; slots[1] = (byte)PlayerSlotOccupancy.AI; MatchFingerprint fingerprint = MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots, diff --git a/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs index 8b6d0fd..58cc4ec 100644 --- a/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs @@ -51,15 +51,16 @@ public Fixture(long startingCredits = 1000, int capacity = 64, System.Action /// Places a completed Barracks at (10,10) and returns its raw wire - /// id. Also places a completed Power plant at (40,40) unless + /// id. Also places a completed HQ at (40,40) unless /// is false — a Barracks draws 15, - /// so a powered grid keeps production at full speed. + /// so the HQ keeps production at full speed and provides the + /// canonical 2,000 AE storage capacity used by refund tests. /// public uint SpawnBarracks(byte slot, bool withPower = true) { if (withPower) { - Assert.That(Construction.PlaceCompletedBuilding(slot, 5, 40, 40).IsValid, Is.True); + Assert.That(Construction.PlaceCompletedBuilding(slot, 3, 40, 40).IsValid, Is.True); } EntityId id = Construction.PlaceCompletedBuilding(slot, 7, 10, 10); Assert.That(id.IsValid, Is.True); @@ -308,6 +309,20 @@ public void CancelProduction_QueuedEntry_FullRefund_RunningEntryUntouched() Assert.That(remaining, Is.EqualTo((ushort)1), "the running entry is untouched"); } + [Test] + public void CancelProduction_RefundIsCappedAtStorageCeiling() + { + var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE); + uint barracks = f.SpawnBarracks(0); + Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); // 2.000 - 120 = 1.880 + f.Economy.GetPlayerEconomy(0).AddCredits(115); // raw fixture setup: 1.995 + + Assert.That(f.Production.CancelProduction(barracks, 0), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 120 AE refund fit; the overflow is forfeit"); + } + [Test] public void EntityStoreFull_QueuePauses_ResumesAfterSpace() { diff --git a/Assets/Tests/EditMode/Simulation/ReplayV1TestUtil.cs b/Assets/Tests/EditMode/Simulation/ReplayV1TestUtil.cs index 8619e91..5133828 100644 --- a/Assets/Tests/EditMode/Simulation/ReplayV1TestUtil.cs +++ b/Assets/Tests/EditMode/Simulation/ReplayV1TestUtil.cs @@ -180,7 +180,7 @@ internal static byte[] StandardFactions() internal static MatchFingerprint CreateFingerprint(TestHost host, ulong seed, byte[] slots = null) { return MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots ?? StandardSlots(), diff --git a/Assets/Tests/EditMode/Simulation/ReplayV1Tests.cs b/Assets/Tests/EditMode/Simulation/ReplayV1Tests.cs index da82886..54975ba 100644 --- a/Assets/Tests/EditMode/Simulation/ReplayV1Tests.cs +++ b/Assets/Tests/EditMode/Simulation/ReplayV1Tests.cs @@ -175,6 +175,28 @@ public void FingerprintMismatch_DifferentStartSeed_RefusesPlayback() "a refused start must not touch the kernel"); } + [Test] + public void FingerprintMismatch_LegacyEmptyRules_RefusesPlaybackBeforeTickOne() + { + ReplayV1TestUtil.LiveMatch live = ReplayV1TestUtil.RunLiveMatch(); + MatchFingerprint legacyRules = MatchFingerprint.CreateCurrent( + MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + 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.IsFalse( + ReplayPlayer.TryPlay( + live.ReplayBytes, legacyRules, playback.Kernel, playback.Ingress, + out ReplayPlaybackError error, out string detail)); + Assert.AreEqual(ReplayPlaybackError.FingerprintMismatch, error); + StringAssert.Contains("RulesHash64", detail); + Assert.AreEqual(0u, playback.Kernel.CurrentTick.Value, + "an old/new rules mismatch must be refused before execution"); + } + [Test] public void FingerprintMismatch_DifferentSlotOccupancy_RefusesPlayback() { diff --git a/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs b/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs index 3ee34c6..6762d1c 100644 --- a/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs +++ b/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs @@ -220,7 +220,7 @@ public void DefinitionsHash64_IsStable_CoversBothFactions_AndIsNotAStub() "the same table must hash identically every time"); Assert.That(hash, Is.Not.EqualTo(MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions)), "the real table hash replaces the empty-content stub"); - Assert.That(hash, Is.Not.EqualTo(MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules))); + Assert.That(hash, Is.Not.EqualTo(MatchFingerprint.ComputeCurrentRulesHash64())); Assert.That(hash, Is.Not.EqualTo(MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map))); // Row coverage: mutating ANY single row — first Alliance, first diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs index 4951166..66ba566 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs @@ -683,7 +683,7 @@ private void SubmitNetworkProof(MatchConfig config) byte[] snapshot = Runner.Kernel.SaveSnapshot(); MatchFingerprint fingerprint = MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), SimDefinitions.ComputeDefinitionsHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), occupancy, diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index d4eb2dd..f103b3a 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -223,8 +223,9 @@ public ConstructionSystem(EntityManager entityManager, EconomySystem economy, Co _occupied = new byte[GridSize * GridSize]; _costField = costField; // 16.3 (#44): a site carries its definition role, so the power - // recompute can no longer skip sites by role — it skips them via - // this register instead. Bound here so no host can forget it. + // and capacity scans can no longer skip sites by role. Both use + // this authoritative register; binding here means no host can + // forget the dependency. _economy.BindSiteLookup(IsActiveSite); } @@ -304,7 +305,7 @@ public bool IsCompletedPlacement(uint rawEntityId) /// /// True while the entity is an unfinished site (16.3, #44: sites now /// carry their definition role, so role alone no longer tells a site - /// apart). Bound into the economy's power recompute via + /// apart). Bound into the economy's power and capacity scans via /// ; also the read the /// presentation layer needs to keep the site look until completion. /// @@ -518,7 +519,8 @@ public bool CancelConstruction(uint rawEntityId) EntityId id = UnitCommandStateView.ToEntityId(rawEntityId); if (_entityManager.TryGetUnit(id, out UnitState unit)) { - _economy.GetPlayerEconomy(unit.PlayerId).AddCredits((long)def.CostAE * CancelRefundPercent / 100); + // 16.4: refunds obey the derived ceiling too — overflow is forfeit. + _economy.DepositCapped(unit.PlayerId, (long)def.CostAE * CancelRefundPercent / 100); } _entityManager.DespawnUnit(id); FreeFootprint(site.OriginX, site.OriginY); @@ -542,7 +544,8 @@ public bool SellBuilding(uint rawEntityId) EntityId id = UnitCommandStateView.ToEntityId(rawEntityId); if (_entityManager.TryGetUnit(id, out UnitState unit)) { - _economy.GetPlayerEconomy(unit.PlayerId).AddCredits((long)def.CostAE * SellRefundPercent / 100); + // 16.4: refunds obey the derived ceiling too — overflow is forfeit. + _economy.DepositCapped(unit.PlayerId, (long)def.CostAE * SellRefundPercent / 100); } _entityManager.DespawnUnit(id); FreeFootprint(placement.OriginX, placement.OriginY); diff --git a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs index 8a05268..e38d655 100644 --- a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs +++ b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs @@ -47,7 +47,23 @@ namespace Nova.Simulation.Economy /// closing the distance is Movement's concern. A harvester with a /// standing order deposits its /// full cargo at an own refinery in reach (same Chebyshev rule): credits - /// rise by exactly the cargo amount and the return leg resolves. + /// rise by the cargo amount THAT FITS under the storage ceiling (16.4 — + /// overflow is forfeit) and the return leg resolves. + /// + /// + /// Storage ceiling (16.4, #53, D-024/D-096/D-106): the AE account has a + /// derived upper bound — one or more completed HQs provide the single + /// 2.000 AE account base, + /// every completed Storage adds 2.000, scanned from the living building + /// stock on every read and NEVER stored (a stored cap would be a state + /// field and a format break). All income and refunds route through + /// and clamp at the ceiling ("Überschuss + /// verfällt"); an EXISTING balance above it decays by 25% of the excess + /// once per second (tick % , integer + /// floor, minimum 1 AE). D-106 refines the D-024/D-096 destruction loss: + /// a destroyed or sold storage drops the ceiling and that new excess + /// enters the same decay instead of applying a separate event-bound loss. + /// Keyed to the tick number — stateless and restore-safe. /// /// /// Auto-cycle (harvest -> return -> harvest, Q-040 resolution): the @@ -127,6 +143,25 @@ public sealed class EconomySystem : IStatefulSimSystem, ISlotFactionLookup /// public const long CanonicalMatchStartingCreditsAE = 3000L; + /// + /// 16.4 (#53, D-024/D-096/D-106): one-time AE account base while the + /// slot owns at least one completed HQ. Additional HQs do not stack. + /// Deliberately below the canonical start balance (3.000 AE, D-077): + /// the start stock stays (existing balances only decay), but fresh + /// income forfeits until the player builds storage — the D-024 silo + /// pressure from the first minute. + /// + public const long HqBaseCapacityAE = 2000L; + + /// 16.4 (#53, D-024): AE capacity bonus per completed Storage. + public const long StorageCapacityBonusAE = 2000L; + + /// 16.4 (#53, D-024): excess balance decay in percent of the excess per decay tick (integer floor, minimum 1 AE). + public const int ExcessDecayPercent = 25; + + /// 16.4 (#53): excess decay cadence — once per second on the canonical 10 Hz clock, keyed to the tick number (stateless, restore-safe). + public const int ExcessDecayIntervalTicks = 10; + /// /// Faction-resolved harvester cargo capacities, indexed by raw /// and resolved once from @@ -150,9 +185,9 @@ public sealed class EconomySystem : IStatefulSimSystem, ISlotFactionLookup /// /// Construction-site lookup bound by the ConstructionSystem /// constructor (16.3, #44): a site entity carries its definition role - /// now, so the power recompute needs the site's own register to tell - /// "unfinished" from "completed". Null in a rig without construction - /// — every building-role entity then counts, the pre-16.3 behaviour. + /// now, so power and capacity scans need the site's own register to + /// tell "unfinished" from "completed". Null in a rig without + /// construction — every building-role entity then counts. /// private Func _isSiteLookup; @@ -199,6 +234,60 @@ public void BindSiteLookup(Func isSiteLookup) _isSiteLookup = isSiteLookup; } + /// + /// 16.4 (#53, D-024/D-096/D-106): the slot's AE ceiling, DERIVED from the + /// living building stock on every read — never stored (a stored cap + /// would be a state-field and format break). One or more completed HQs + /// provide the single 2.000 AE account base; every completed Storage + /// adds 2.000. + /// Sites are excluded via the bound lookup: a half-built silo holds + /// nothing. Without the lookup (construction-free rigs) every + /// building-role entity counts. Integer scan in ascending entity + /// order — deterministic, restore-safe. + /// Invalid player ids have no account and return zero. + /// + public long CapacityFor(byte playerId) + { + if (playerId >= MaxPlayers) return 0; + long capacity = 0; + bool hasCompletedHq = false; + UnitState[] units = _entityManager.RawUnits; + int count = _entityManager.Capacity; + for (int i = 0; i < count; i++) + { + ref readonly UnitState unit = ref units[i]; + if (!unit.IsActive || unit.PlayerId != playerId) continue; + if (_isSiteLookup != null && _isSiteLookup(unit.Id)) continue; + if (unit.Role == UnitRole.HQ) + { + hasCompletedHq = true; + } + else if (unit.Role == UnitRole.Storage) + { + capacity += StorageCapacityBonusAE; + } + } + return hasCompletedHq ? capacity + HqBaseCapacityAE : capacity; + } + + /// + /// 16.4 (#53, D-024): the capped deposit — the ONLY way income and + /// refunds should land. What does not fit under + /// is forfeit ("Überschuss verfällt"); an existing balance above the + /// ceiling is NOT touched here (it decays per second, see ExecuteTick). + /// Returns the amount actually deposited (0 when the account is at or + /// above the ceiling). + /// + public long DepositCapped(byte playerId, long amount) + { + if (amount <= 0 || playerId >= MaxPlayers) return 0; + ref PlayerEconomyState eco = ref _players[playerId]; + long room = CapacityFor(playerId) - eco.AetheriumCredits; + if (room <= 0) return 0; + long deposited = Math.Min(amount, room); + eco.AddCredits(deposited); + return deposited; + } /// Mutable access to one slot's economy state (slot must be in [0, MaxPlayers)). public ref PlayerEconomyState GetPlayerEconomy(byte playerId) { @@ -337,12 +426,40 @@ public void SetSlotFaction(byte playerId, FactionId faction) /// Phases 2 and 3 of the canonical tick (SimulationCore.md section /// 2): power recompute, then the harvest cycle — both in strict /// ascending entity-index order, before movement runs (registration - /// order; see class remarks). + /// order; see class remarks). Once per second the excess-balance + /// decay runs (16.4, #53, D-024/D-106): a balance above the derived + /// ceiling loses 25% of the excess per decay tick (integer floor, + /// minimum 1 AE, so it always converges). D-106 refines the destruction + /// rule: a destroyed (or sold) storage drops the ceiling and the new + /// excess enters this decay, with no separate remembered event. Keyed + /// to the tick number — stateless and restore-safe. /// public void ExecuteTick(Tick tick) { RecomputePower(); ExecuteHarvest(); + if (tick.Value % ExcessDecayIntervalTicks == 0) + { + DecayExcessBalances(); + } + } + + /// The per-second excess decay (16.4): every slot above its derived ceiling loses a quarter of the excess. + private void DecayExcessBalances() + { + for (byte p = 0; p < MaxPlayers; p++) + { + ref PlayerEconomyState eco = ref _players[p]; + long excess = eco.AetheriumCredits - CapacityFor(p); + if (excess <= 0) continue; + // Split quotient and remainder before multiplication so every + // valid restored long balance — including long.MaxValue — is + // handled without overflow while preserving integer floor. + long loss = excess / 100L * ExcessDecayPercent + + excess % 100L * ExcessDecayPercent / 100L; + if (loss < 1L) loss = 1L; + eco.AetheriumCredits -= loss; + } } public void Shutdown() @@ -487,8 +604,9 @@ private void ExecuteHarvestOrder(ref UnitState unit) } /// - /// One return order: deposits the full cargo at an own refinery in - /// reach (credits rise by exactly the cargo); holds out of reach. + /// One return order: empties the full cargo at an own refinery in + /// reach; credits rise only by the amount that fits below the derived + /// storage ceiling and overflow is forfeit. Holds out of reach. /// Clearing the returning flag alone resumes an auto-cycle, because /// the retained is picked up by /// the harvest branch on the next tick. A command-issued return @@ -504,7 +622,7 @@ private void ExecuteReturnOrder(ref UnitState unit) if (!HasOwnRefineryInReach(in unit)) return; // held, not dropped - _players[unit.PlayerId].AddCredits(unit.CargoAE); + DepositCapped(unit.PlayerId, unit.CargoAE); // 16.4: capped at the derived ceiling — overflow is forfeit unit.CargoAE = 0; unit.IsReturningCargo = false; } diff --git a/Assets/_Project/Scripts/Simulation/Economy/PlayerEconomyState.cs b/Assets/_Project/Scripts/Simulation/Economy/PlayerEconomyState.cs index e32590c..f702d38 100644 --- a/Assets/_Project/Scripts/Simulation/Economy/PlayerEconomyState.cs +++ b/Assets/_Project/Scripts/Simulation/Economy/PlayerEconomyState.cs @@ -74,7 +74,12 @@ public PlayerEconomyState(byte playerId, long startingCredits = 1000, FactionId PowerRequired = 0; } - /// Adds a non-negative amount of credits (harvest deposits of this slice). + /// + /// Adds a non-negative amount of credits (raw write). Callers route + /// through instead (16.4, + /// #53, D-024): income and refunds obey the derived storage ceiling — + /// only the ceiling rule itself and tests touch this directly. + /// public void AddCredits(long amount) { if (amount > 0) diff --git a/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs b/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs index d19d0af..a9748cb 100644 --- a/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs @@ -344,8 +344,8 @@ public bool CancelProduction(uint buildingRaw, int queueIndex) EntityId id = UnitCommandStateView.ToEntityId(buildingRaw); if (_entityManager.TryGetUnit(id, out UnitState building)) { - _economy.GetPlayerEconomy(building.PlayerId) - .AddCredits((long)def.CostAE * row.Entries[queueIndex].RemainingCount); + // 16.4: refunds obey the derived ceiling too — overflow is forfeit. + _economy.DepositCapped(building.PlayerId, (long)def.CostAE * row.Entries[queueIndex].RemainingCount); } RemoveEntry(row, queueIndex); return true; diff --git a/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs b/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs index 645ecb5..aed952f 100644 --- a/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs +++ b/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs @@ -2,6 +2,7 @@ using System.Text; using Nova.Core; using Nova.Simulation.CommandsV1; +using Nova.Simulation.Economy; using Nova.Simulation.Snapshots; namespace Nova.Simulation.Replays @@ -20,12 +21,14 @@ public enum PlayerSlotOccupancy : byte } /// - /// Stub selector for the deterministic stand-in content hashes used until - /// canonical rules/definitions/map sources exist (Q-040 candidate). + /// Stub selector for deterministic stand-in content hashes. Rules now use + /// in current + /// fingerprints; the Rules selector remains only to identify/refuse the + /// legacy empty stub and for compatibility tests. /// public enum MatchContentStub : uint { - /// Stub for RulesHash64. + /// Legacy empty stub for RulesHash64; not used by current hosts. Rules = 1, /// Stub for DefinitionsHash64. @@ -100,6 +103,14 @@ public sealed class MatchFingerprint : IEquatable /// The only PRNG id of schema v1 (SimulationCore.md section 1). public const string PrngIdV1 = "XorShift128PlusV1"; + /// + /// Current deterministic rules revision. Revision 1 is the first + /// non-stub rules identity and binds the D-106 storage-cap behavior. + /// Behavior changes covered by + /// must bump this value or change one of the bound constants. + /// + public const ushort RulesRevisionV1 = 1; + /// Parser bound for one identifier string; checked before allocation. public const int MaxIdentifierBytes = 64; @@ -223,8 +234,8 @@ public static MatchFingerprint CreateCurrent( } /// - /// Deterministic stand-in content hash until canonical - /// rules/definitions/map sources exist (Q-040 candidate): XXH64 seed 0 + /// Deterministic empty stand-in content hash for legacy/test inputs and + /// content domains without a canonical source yet: XXH64 seed 0 /// in the NOVA_DEFINITIONS_V1 domain over a stub field tag and an /// empty item list (u32 count = 0). Distinct tags keep the three /// stubs distinct; every host computes the identical value. @@ -237,6 +248,32 @@ public static ulong ComputeEmptyContentStubHash(MatchContentStub stub) return hash.Digest(); } + /// + /// Canonical rules identity for the current simulation. Unlike the + /// legacy empty Rules stub, this binds the D-106 economy 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 instead of desynchronizing at the first + /// excess-decay tick. + /// + public static ulong ComputeCurrentRulesHash64() + { + var hash = SimHashWriter.ForDefinitions(); + hash.WriteFieldTag((uint)MatchContentStub.Rules); + hash.WriteUInt32(5); // ordered rule fields below + hash.WriteFieldTag(1); + hash.WriteUInt16(RulesRevisionV1); + hash.WriteFieldTag(2); + hash.WriteInt64(EconomySystem.HqBaseCapacityAE); + hash.WriteFieldTag(3); + hash.WriteInt64(EconomySystem.StorageCapacityBonusAE); + hash.WriteFieldTag(4); + hash.WriteInt32(EconomySystem.ExcessDecayPercent); + hash.WriteFieldTag(5); + hash.WriteInt32(EconomySystem.ExcessDecayIntervalTicks); + return hash.Digest(); + } + /// Occupancy of one reserved slot (index 0..7). public PlayerSlotOccupancy GetSlotOccupancy(int slot) { diff --git a/Assets/_Project/Scripts/Simulation/State/UnitRole.cs b/Assets/_Project/Scripts/Simulation/State/UnitRole.cs index 6998b23..dc886ed 100644 --- a/Assets/_Project/Scripts/Simulation/State/UnitRole.cs +++ b/Assets/_Project/Scripts/Simulation/State/UnitRole.cs @@ -51,7 +51,7 @@ public enum UnitRole : byte /// Power plant building; provides power to its owner's grid. Power = 5, - /// Storage building (MS-1 role; no canonical behavior beyond power draw yet). + /// Storage building; each completed placement adds 2,000 AE to its owner's derived account capacity (D-106). Storage = 6, /// Barracks building; produces the infantry unit roles. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e6e160..a38c3bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,25 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de sie belegt keine Verbesserung. ### Behoben +- **#53: Das Lager begrenzt das Konto (D-024/D-096/D-106)** — das Aetherium-Konto hat + jetzt eine aus dem Gebäudebestand abgeleitete Obergrenze, nichts wird + gespeichert (kein Zustandsfeld, kein Formatbruch): ein oder mehrere fertige + HQs geben zusammen genau eine Basis von 2.000 AE, jedes fertige Lager + +2.000; Baustellen zählen nicht. Jede + Einzahlung (Ernte, Rückerstattungen bei Streichung, Abbruch, Verkauf) läuft + über `EconomySystem.DepositCapped` und deckelt hart — Überschuss verfällt. + Ein Bestand über der Grenze zerfällt einmal pro Sekunde um 25 % des + Überschusses (getaktet über den Sim-Tick, zustandslos, restore-sicher): das + ist der „25 % Verlust bei Zerstörung" ohne Ereignis getragen — ein + zerstörtes oder verkauftes Lager senkt die Grenze, der Zerfall ist der + Verlust. D-106 präzisiert die Zerstörungsregel als zustandslosen Zerfall + statt Slot-gebundenem Einmalverlust und bindet Revision sowie + 2.000/2.000/25/10 erstmals in `RulesHash64`: alte Dateien bleiben lesbar, + eine Wiedergabe oder ein Lockstep-Start mit dem alten leeren Rules-Stub wird + vor Tick 1 abgelehnt. Der kanonische KI-Endzustand + bleibt bei Tick 2.546 entschieden und bewegt sich durch diese Wirtschaftsregel + von `0x9F93097AD526B6F7` auf `0xE784E6184AD16081`; die KI-Kennung bleibt + unverändert `r6.E34435F9` - **#44: Baustellen schiessen nicht mehr** — die Baustelle trägt jetzt ihre Definitionsrolle statt `UnitRole.Unit`; der bewaffnete Fallback-Slot der Waffentabelle greift nicht mehr, und `CombatSystem` schliesst zusätzlich jede aktive diff --git a/docs/gamedesign/Buildings.md b/docs/gamedesign/Buildings.md index af1262a..d355a2f 100644 --- a/docs/gamedesign/Buildings.md +++ b/docs/gamedesign/Buildings.md @@ -1,6 +1,6 @@ # Gebäude – alle Fraktionen -**Version:** 0.5.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 4 +**Version:** 0.6.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 16 ## Zweck @@ -8,7 +8,7 @@ Spielbare Spezifikation aller 12 Gebäudetypen (D-008) für die drei Fraktionen ## Abhängigkeiten -- [../production/DecisionLog.md](../production/DecisionLog.md) – D-008 (12 Typen), D-010 (Aetherium-Wirtschaft), D-011 (Evolvierte-Wachstum), D-012 (Zerstörbarkeit), D-022 (Capture-System), D-023 (Superwaffen-Limit), D-024 (Lager & Raffinerie), D-030 (Low-Power/Forschung) +- [../production/DecisionLog.md](../production/DecisionLog.md) – D-008 (12 Typen), D-010 (Aetherium-Wirtschaft), D-011 (Evolvierte-Wachstum), D-012 (Zerstörbarkeit), D-022 (Capture-System), D-023 (Superwaffen-Limit), D-024 (Lager & Raffinerie), D-030 (Low-Power/Forschung), D-077 (MS-1-Startzustand), D-096/D-106 (abgeleitete AE-Grenze und Ü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 @@ -38,10 +38,10 @@ 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). -- **Lagerkapazität (D-024):** HQ-Basiskapazität 2.000 AE, +2.000 AE je Lager-Gebäude; Überschuss über der Kapazität verfällt; bei Lager-Zerstörung geht ein anteiliger AE-Bestand (25 %) verloren. +- **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. -- **Währung:** AE (Aetherium-Einheiten). Referenz: Startressourcen 1.000 AE, Harvester-Ladung ~300 AE, Ziel-Matchdauer 20–35 min (D-010). +- **Währung:** AE (Aetherium-Einheiten). MS-1-Referenz: Startressourcen 3.000 AE (D-077), Harvester-Ladung ~300 AE, Ziel-Matchdauer 20–35 min (D-010). ## 2. Gebäudeübersicht (12 Typen × 3 Fraktionen) @@ -55,7 +55,7 @@ Voraussetzungsschlüssel: HQ = Hauptquartier, KW = Kraftwerk, Raff = Raffinerie, | Legion | Gefechtsstand | 2.000 AE | 50 s | +30 / −0 | Schwer | | Evolvierte | Herzkristall | 2.300 AE | 55 s (Reifung) | +30 / −0 | Schwer | -- Rolle: Startgebäude, produziert Baufahrzeuge (Allianz/Legion) bzw. Keimträger (Evolvierte), einzige Bauwarteschlange für Gebäude; Niederlage-Bedingung zusammen mit allen Produktionsgebäuden; AE-Basiskapazität 2.000 AE (D-024, siehe §2.4). +- Rolle: Startgebäude, produziert Baufahrzeuge (Allianz/Legion) bzw. Keimträger (Evolvierte), einzige Bauwarteschlange für Gebäude; Niederlage-Bedingung zusammen mit allen Produktionsgebäuden; mindestens ein fertiges HQ aktiviert die einmalige AE-Kontobasis von 2.000 AE, weitere HQs stapeln sie nicht (D-106, siehe §2.4). - Voraussetzung: keine (Start); Neuaufbau nach Verlust möglich, erst ab T2 über FL-Forschung "Basis-Neugründung" (`SPC_REBASE`, bestätigt im Korrekturlauf Sprint 2). **Ausführungsmechanik (D-031.1):** Nach abgeschlossener Forschung errichtet ein Builder-Fahrzeug (Allianz/Legion) bzw. das Evolvierte-Builder-Äquivalent das neue HQ **eigenständig vor Ort** – außerhalb der HQ-Bau-Queue (§8); die Queue-Regel (Gebäudebau nur über das HQ) bleibt für alle anderen Gebäude unverändert. - Besonderheit: definiert den Bau-Einflussradius (siehe §6); begrenzte Grundenergie (+30, führend gemäß D-032) damit die Frühphase ohne sofortiges Kraftwerk spielbar ist. @@ -91,9 +91,9 @@ Voraussetzungsschlüssel: HQ = Hauptquartier, KW = Kraftwerk, Raff = Raffinerie, | Legion | Bunkerdepot | 250 AE | 8 s | −5 | Leicht | | Evolvierte | Speicherkammer | 275 AE | 9 s (Reifung) | −5 | Leicht | -- Rolle: erhöht AE-Lagerkapazität (+2.000 AE je Lager, D-024); Basiskapazität des HQ: 2.000 AE; Überschuss ohne Kapazität verfällt. +- Rolle: erhöht die abgeleitete AE-Lagerkapazität um +2.000 AE je fertigem, lebendem Lager (D-106); die HQ-Kontobasis beträgt einmalig 2.000 AE. Baustellen geben keine Kapazität. - Voraussetzung: Raff. -- Besonderheit: bei Zerstörung geht ein anteiliger AE-Bestand (25 %) verloren (D-024; Angriffsziel mit wirtschaftlichem Effekt). +- Besonderheit: Zerstörung oder Verkauf senkt die Grenze sofort. Der dadurch entstehende Überhang verliert einmal pro Sekunde 25 % seines jeweils aktuellen Werts bis zur neuen Grenze; es gibt keinen zusätzlichen Einmalverlust (D-106; Angriffsziel mit wirtschaftlichem Effekt). ### 2.5 Kaserne @@ -267,3 +267,4 @@ Entschieden und entfernt im Korrekturlauf Sprint 4: HQ-Grundenergie (+30 führen | 0.4.0 | 2026-07-21 | Korrekturlauf Sprint 4 (D-043–D-052, Review-Findings): als führende Quelle für Gebäudekosten/-energie/-bauzeiten festgelegt (Review F-03, D-047-Grundsatzregel); Offene Punkte bereinigt | Lead Gameplay Designer | | 0.4.1 | 2026-07-21 | Offener Punkt "Flak-DPS-Korridor" geschlossen: veralteter Querverweis auf Aircraft.md (90 DPS) entfernt, auf Weapons.md als einzige Werte-Quelle (25–40 Schaden/1,5 s, ×2,0 vs. Luft ≈ 33–53 DPS, D-047) umformuliert | Lead Gameplay Designer | | 0.5.0 | 2026-07-24 | Neun Gebäuderollen, Start-Ausnahme, T2-Freischaltung und MG-/Raketenmodule für MS-1 gemäß D-056 abgegrenzt | Lead Gameplay Designer | +| 0.6.0 | 2026-08-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 | diff --git a/docs/gamedesign/Economy.md b/docs/gamedesign/Economy.md index 8d985b4..9d3e9ac 100644 --- a/docs/gamedesign/Economy.md +++ b/docs/gamedesign/Economy.md @@ -1,6 +1,6 @@ # Wirtschaftssystem (Economy) -**Version:** 0.4.0 | **Status:** Entwurf (Korrekturlauf Sprint 4) | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 4 +**Version:** 0.5.0 | **Status:** Entwurf – 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) +- [../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) - [./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 @@ -23,7 +23,9 @@ Spezifiziert den Wirtschafts-Kreislauf von Project Nova: Sammler-Loop, Lagerkapa | AE (Aetherium-Einheiten) | Einzige Bau-/Produktionswährung | Ernte an Feldern, Objective-Belohnungen 400/800/1.200 AE je Lager-Stufe (D-016, siehe [./NeutralUnits.md](./NeutralUnits.md)) | | Energie | Versorgt Gebäude; Defizit löst Low-Power aus | Kraftwerke | -Standard-Startressourcen: **1.000 AE** (Zahlengerüst), Start-Energie 0 (erstes Kraftwerk Pflichtfrühinvestition). +MS-1-Startressourcen: **3.000 AE** (D-077), Start-Energie 0; das fertige HQ +liefert +30 Grundenergie. Ohne fertiges Lager liegt der Startbestand 1.000 AE +über der HQ-Basis und erzeugt damit sofort den Ausgaben-/Bauanreiz aus D-106. ## Sammler-Loop @@ -41,14 +43,16 @@ Standard-Startressourcen: **1.000 AE** (Zahlengerüst), Start-Energie 0 (erstes Harvester sind unbewaffnet (Allianz/Legion) und haben mittlere Panzerung; Evolvierte-Sammler ("Zermahler") regenerieren langsam (D-011-Logik auf Einheitenebene, Details in Vehicles.md). -## Lager- und Kapazitätsregeln (gemäß D-024) +## Lager- und Kapazitätsregeln (D-024/D-096/D-106) | Regel | Wert v0.2 | |---|---| -| Konto-Grundkapazität | 2.000 AE (HQ-Basis) – der Startwert von 1.000 AE liegt darunter; Lager werden ab der ersten Expansion relevant | -| Kapazität pro Lager-Gebäude | +2.000 AE | -| Überschuss-Regel | AE über der Kapazität **verfallen** (Ablade-Vorgang kappt auf Maximum) – klassische Silo-Regel als Ausgaben-/Bauanreiz | -| Lager-Verlust | Zerstörtes Lager: **anteiliger Verlust von 25 % des gelagerten AE** (D-024; Detailwert gemäß [./Buildings.md](./Buildings.md)), zusätzlich entfällt die gebundene Kapazität | +| Konto-Grundkapazität | Genau 2.000 AE, solange mindestens ein fertiges, lebendes HQ existiert; mehrere HQs stapeln die Basis nicht | +| Kapazität pro Lager-Gebäude | +2.000 AE je fertigem, lebendem Lager; Baustellen zählen nicht | +| Neue Einzahlungen | Ernte und Rückerstattungen werden an der aktuellen Grenze hart gekappt; der nicht passende Anteil verfällt | +| Vorhandener Überhang | Alle 10 Sim-Ticks (1 s) verfallen 25 % des aktuellen AE-Anteils oberhalb der Grenze, ganzzahlig abgerundet und mindestens 1 AE, bis die Grenze erreicht ist | +| Kapazitätsverlust | Zerstörung oder Verkauf eines Lagers sowie Verlust des letzten HQ senken die Grenze sofort; der neue Überhang folgt derselben periodischen Regel, ohne zusätzlichen Einmalverlust | +| Verkauf-Reihenfolge | Die Rückerstattung wird noch gegen die vor dem Despawn geltende Kapazität gedeckelt; anschließend sinkt die Grenze | ## Energie-System und Low-Power @@ -82,7 +86,7 @@ Fraktions-Profil-Faktor: Allianz ×1,15 (teuer, stark), Legion ×0,85 (günstig, ### Gebäude (12 Typen gemäß D-008) -Kosten, Energiebilanz und Bauzeiten aller Gebäude stehen **ausschließlich in [./Buildings.md](./Buildings.md)** (führendes Dokument, Review F-03). Economy.md legt für Gebäude nur den systemischen Rahmen fest: Startressourcen (1.000 AE), Einkommensraten-Ziele, Low-Power-Regel, Lager-/Kapazitätsregeln (D-024) und Reparatur-/Verkaufsregeln (Prozentwerte unten). +Kosten, Energiebilanz und Bauzeiten aller Gebäude stehen **ausschließlich in [./Buildings.md](./Buildings.md)** (führendes Dokument, Review F-03). Economy.md legt für Gebäude nur den systemischen Rahmen fest: MS-1-Startressourcen (3.000 AE, D-077), Einkommensraten-Ziele, Low-Power-Regel, Lager-/Kapazitätsregeln (D-024/D-096/D-106) und Reparatur-/Verkaufsregeln (Prozentwerte unten). ### Einheiten (Rahmen pro Kategorie und Tech-Tier) @@ -94,7 +98,11 @@ Kosten, Energiebilanz und Bauzeiten aller Gebäude stehen **ausschließlich in [ | Drohnen (2–3/Fraktion, D-014) | 200–400 | – | – | | Elite-Einheit (D-015, 1× MVP) | – | – | 3.000–4.000 | -Begründung: Mit 1.000 AE Start und ~600 AE/min ist Tier 1 sofort, Tier 2 nach ~4–6 min, Tier 3 nach ~12–15 min erreichbar – passt zur Ziel-Matchdauer 20–35 min. Feinwerte pro Einheit in den Einheitendokumenten. +Begründung: Mit 3.000 AE MS-1-Start (D-077) und ~600 AE/min ist der klassische +Refinery-/Harvester-Aufbau sofort finanzierbar; die 2.000-AE-HQ-Basis erzeugt +gleichzeitig Druck, früh auszugeben oder ein Lager zu errichten. Tier 2 bleibt +nach ~4–6 min, Tier 3 nach ~12–15 min erreichbar – passend zur Ziel-Matchdauer +20–35 min. Feinwerte stehen in den Einheitendokumenten. ## Reparatur- und Verkaufsregeln @@ -119,7 +127,7 @@ Harvester-Kosten sind **führend in [./Vehicles.md](./Vehicles.md)** definiert ( | Phase | Zeitraum | Wirtschaftlicher Ist-Zustand (Ziel) | |---|---|---| -| Aufbau | 0–5 min | 1.000 AE Start + Stufe-1-Einkommen; erstes Kraftwerk, Kaserne, erste Harvester-Ergänzung | +| Aufbau | 0–5 min | 3.000 AE MS-1-Start + Stufe-1-Einkommen; Raffinerie-/Harvester-Loop, erstes Kraftwerk und Kaserne | | Expansion | 5–12 min | Startfeld-Reserve ~50 % verbraucht → Zwang zu Stufe 2; erste Feld-Konflikte | | Dominanz | 12–22 min | Stufe 3 nötig für Tier 3/Elite/Superwaffe; Überernte-Entscheidungen (schnell auspressen vs. nachhaltig) werden matchrelevant | | Endspiel | 22–35 min | Zentrale Felder erschöpft oder zerstört; Einkommen sinkt natürlich auf ~Stufe-2-Niveau → Matches enden durch Druck, nicht durch Ressourcen-Timeout | @@ -156,3 +164,4 @@ Leitplanke: Gesamt-AE-Fluss pro Spieler über ein typisches 25-min-Match ≈ 25. | 0.2.0 | 2026-07-21 | Korrekturlauf Sprint 2 (D-020–D-030) | Lead Gameplay Designer | | 0.3.0 | 2026-07-21 | Korrekturlauf Sprint 4 (D-043–D-052, Review-Findings): Gebäudekosten/-energie durch Verweise auf Buildings.md ersetzt (Review F-03, D-047-Grundsatzregel); Economy.md behält nur Systemlogik (Raten, Low-Power, Lager) | Lead Gameplay Designer | | 0.4.0 | 2026-07-21 | F-03 vollständig geschlossen: Harvester-Kosten in der Fraktions-Wirtschaftsmodifier-Tabelle durch Verweis auf die führende Quelle [Vehicles.md](./Vehicles.md) ersetzt (D-047) – keine dritte Zahl mehr neben Vehicles.md (700/550/620 AE) | Lead Gameplay Designer | +| 0.5.0 | 2026-08-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 | diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md index 4c7bc4f..4d9d73d 100644 --- a/docs/production/DecisionLog.md +++ b/docs/production/DecisionLog.md @@ -1,6 +1,6 @@ # Decision Log -**Version:** 1.34.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 16 +**Version:** 1.35.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 16 ## Zweck @@ -211,7 +211,7 @@ als Post-MVP-/Vollspiel-Zielbild bestehen. **Begründung:** Lesbarkeit und Endspiel-Dramaturgie; unbegrenzte Superwaffen degradieren sie zum Wirtschafts-Spam. **Konsequenzen:** Buildings.md/Weapons.md/GameLoop.md angeglichen. -### D-024 | verbindlich | Sprint 2 (Lager & Raffinerie) +### D-024 | teilweise ersetzt durch D-106 | Sprint 2 (Lager & Raffinerie) **Kontext:** Lager-Kapazitätsmechanik (+2.000 AE/Lager) war nicht im Zahlengerüst; Raffinerie-Packaging offen. **Alternativen:** (a) keine Lager-Kapazität (Lager nutzlos); (b) Kapazität mit hartem Erntestopp bei vollem Konto; (c) Kapazität +2.000 AE je Lager, Überschuss verfällt, anteiliger Verlust bei Lager-Zerstörung; Raffinerie wird mit 1 Harvester geliefert. @@ -2650,7 +2650,7 @@ Entscheidung selbst ist unverändert — deshalb keine neue D-ID. --- -### D-096 | verbindlich | Sprint 16 (Lager mit abgeleiteter AE-Obergrenze, Radar schaltet die Minimap frei) +### D-096 | teilweise ersetzt durch D-106 | Sprint 16 (Lager mit abgeleiteter AE-Obergrenze, Radar schaltet die Minimap frei) **Status:** Inhaberentscheidung vom 2026-08-09 (Richtung); die Ausformung in Paketen liegt beim Agenten. Umgesetzt in @@ -2720,7 +2720,9 @@ einzige Variante, die das Gebäude für den Spieler spürbar macht, ohne eine ne Anzeige zu erfinden. **Konsequenzen:** `MatchFingerprint.StateSchemaVersionV1` bleibt unberührt, -vorhandene Snapshots und Replays bleiben lesbar. `AddCredits` hat vier Aufrufer +vorhandene Snapshots und Replays bleiben strukturell lesbar. D-106 präzisiert: +Eine exakte Wiedergabe unter geänderten Regeln wird über `RulesHash64` vor dem +Start abgelehnt. `AddCredits` hat vier Aufrufer — Abladen, Streichung, Abbruch und Verkauf —, alle im Schreibbereich des Netzstrangs. Erst zusammen mit der Low-Power-Abschaltreihenfolge (16.6) wird die Kopplung zur Waffe: ein zerstörtes Kraftwerk nimmt Radar, Verteidigung und damit @@ -3096,6 +3098,92 @@ dokumentierte, begrenzte Integrationsreparatur. PRs mit zurückgestellter Spielabnahme müssen ihr Restrisiko offen tragen und dürfen nicht als vollständig gespielt gemeldet werden. +--- + +### D-106 | verbindlich | Sprint 16 (zustandsloser AE-Überhang und kanonische Regelidentität) + +**Status:** Agentenentscheidung vom 2026-08-10 unter der ausdrücklichen +Delegation des alleinigen Inhabers; nach D-105 überstimmbar. Umgesetzt in +[16_Sprint_Wirtschaft.md](hashkrieg/16_Sprint_Wirtschaft.md) 16.4. + +**Kontext:** D-024 und D-096 verlangen eine aus dem Gebäudebestand abgeleitete +AE-Obergrenze sowie „25 % Verlust bei Zerstörung“, lassen aber drei für die +Simulation entscheidende Punkte offen: ob mehrere HQs die Basis stapeln, ob +25 % vom gesamten Kontostand oder nur vom neuen Überhang verloren gehen und +wie Verkauf, HQ-Verlust oder ein bereits oberhalb der Grenze geladener +Start-/Snapshot-Zustand behandelt werden. + +Der Sprint-16-Strang hat dafür einen zustandslosen Abbau implementiert. Das ist +nicht gleichbedeutend mit einem einmaligen Zerstörungsereignis: Er wirkt auch +ohne Lagerzerstörung und baut den Überhang über mehrere Sekunden vollständig +bis zur Grenze ab. Diese Ausformung braucht deshalb einen eigenen, offen +protokollierten Entscheid statt einer stillen Umdeutung von D-024/D-096. + +**Alternativen:** + +1. **Bei jeder Lagerzerstörung sofort 25 % des gesamten Kontostands abziehen.** + Verworfen: Verkauf, HQ-Verlust und oberhalb der Grenze geladene Zustände + blieben Sonderfälle; außerdem müsste der Economy-Pfad an jede + Zerstörungsursache gekoppelt werden. +2. **Den Kontostand bei jeder Kapazitätssenkung sofort hart auf die neue Grenze + kappen.** Verworfen: Der vollständige Sofortverlust ist deutlich härter als + der beschlossene 25-%-Effekt und gibt kein Reaktionsfenster zum Ausgeben. +3. **Vorhandenen Überhang dauerhaft behalten und nur neue Einzahlungen + blockieren.** Verworfen: Damit hätte die Zerstörungsregel keine + wirtschaftliche Wirkung; gespeicherte Überschüsse könnten unbegrenzt + konserviert werden. +4. **Den aktuellen Überhang zustandslos und periodisch abbauen.** Angenommen: + ein einheitlicher, deterministischer Pfad deckt Startzustand, Restore, + Zerstörung und Verkauf ab, ohne ein neues Snapshot-Feld oder Ereignislog. + +**Entscheidung:** + +1. Ein Slot besitzt genau eine **HQ-Basiskapazität von 2.000 AE**, solange + mindestens ein lebendes, fertiggestelltes HQ existiert. Weitere HQs + stapeln diese Basis nicht. +2. Jedes lebende, fertiggestellte Lager addiert 2.000 AE. Baustellen zählen + weder als HQ noch als Lager. Die Kapazität bleibt vollständig aus dem + Gebäudebestand abgeleitet; `EconomySystem.StateVersion` bleibt unverändert. +3. Jede neue Einzahlung und Rückerstattung wird sofort an der aktuellen + Grenze gekappt; der nicht passende Anteil verfällt. Beim Verkauf wird die + Rückerstattung noch gegen die vor dem Despawn geltende Kapazität gebucht; + erst danach sinkt die abgeleitete Grenze und ein Überhang beginnt abzubauen. +4. Ein bereits vorhandener Kontostand oberhalb der Grenze verliert auf jedem + zehnten Simulationstick (eine Sekunde bei 10 Hz) 25 % des **aktuellen + Überhangs**. Es gilt ganzzahlige Abrundung, mindestens 1 AE je Intervall, + bis der Kontostand die Grenze erreicht. +5. Der Abbau gilt unabhängig von der Ursache des Überhangs: kanonischer + Startbestand, Snapshot-Restore, Zerstörung oder Verkauf eines Lagers sowie + Verlust des letzten HQ. Es gibt daneben keinen separaten Einmalverlust. +6. Die Prozentrechnung muss für jeden gültigen nichtnegativen `long`-Kontostand + überlauffrei sein. `CapacityFor` liefert für einen ungültigen Slot 0; + `DepositCapped` verwirft dessen Einzahlung und liefert ebenfalls 0, damit + diese read-/deposit-Helfer keine Arraygrenze berühren. +7. Die Regelidentität wird erstmals kanonisch in `RulesHash64` gebunden: + Revision 1 plus 2.000/2.000/25/10. Alter Rules-Stub und D-106-Regeln dürfen + niemals denselben Match-Fingerprint bilden. Alte Replay-Dateien und + Snapshots bleiben strukturell lesbar; exakte Replay-Wiedergabe unter einem + anderen Rules-Hash wird vor Tick 1 mit `RulesHash64`-Mismatch abgelehnt. +8. D-106 präzisiert und ersetzt nur die Verlustausformung, HQ-Stapelungsfrage + und die weitergehende Replay-Zusage aus D-024/D-096. Die Radarentscheidung + aus D-096 bleibt unverändert. + +**Begründung:** Die eine Kontobasis hält zusätzliche HQs von der Lagerrolle +frei. Der periodische Überhang ist für alle Ursachen identisch, deterministisch +und restore-sicher; er braucht kein persistiertes Ereignis und gibt dem Spieler +ein kurzes Ausgabenfenster, ohne Überschuss dauerhaft zu schützen. Die harte +Kappung neuer Einnahmen bewahrt gleichzeitig den klassischen Silo-Druck. Der +Rules-Hash verhindert, dass diese Verhaltensänderung als scheinbar kompatibler +Lockstep- oder Replay-Start erst nach Tick 10 desynchronisiert. + +**Konsequenzen:** Der kanonische Start mit 3.000 AE (D-077) liegt ohne Lager +zunächst 1.000 AE über der HQ-Basis und beginnt am Tick 10 zu zerfallen, sofern +er nicht vorher ausgegeben wird. Zerstörung und Verkauf eines Lagers senken die +Grenze sofort; 25 % des dadurch entstehenden aktuellen Überhangs fallen pro +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. + ## Offene Punkte - Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden. @@ -3155,11 +3243,11 @@ gespielt gemeldet werden. „Offene Punkte"-Zeile nennt noch den zu kurzen Bereich D-078 bis D-081 und begründet die Reservierung mit einem inzwischen erfolgten Eintrag (D-077). Beides ist in jener Datei nachzuziehen, nicht hier. -- **D-095 bis D-097 stehen in derselben Delegationslage** wie D-074/D-083 +- **D-092 bis D-094 stehen in derselben Delegationslage** wie D-074/D-083 (Lobby-Vermittlung über Supabase Edge Functions, kurzlebige - HMAC-Match-Tokens, Build-Commit-Exposition zur Laufzeit); bei D-096 hat der + HMAC-Match-Tokens, Build-Commit-Exposition zur Laufzeit); bei D-093 hat der Inhaber die Richtung (HMAC-Token) selbst vorgegeben, die Ausformung sowie - D-095/D-097 hat der Agent unter Delegation entschieden — gekennzeichnet und + D-092/D-094 hat der Agent unter Delegation entschieden — gekennzeichnet und überstimmbar. **Offen darin:** Das Supabase-Projekt ist noch nicht angelegt und die Function-Referenzen in [../tech/LobbySupabase.md](../tech/LobbySupabase.md) noch nicht gegen ein @@ -3176,6 +3264,7 @@ gespielt gemeldet werden. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.35.0 | 2026-08-10 | D-106 aufgenommen: einmalige 2.000-AE-HQ-Basis, +2.000 je fertigem Lager, sofort gedeckelte Einzahlungen, zustandsloser 25-%-Abbau des aktuellen Überhangs je Sekunde und kanonischer Rules-Hash; D-024/D-096 in Verlustausformung, HQ-Stapelung und Replay-Kompatibilität teilweise ersetzt; Fehlverweise der Lobbyfamilie D-095–D-097 auf D-092–D-094 berichtigt | Agent (unter Delegation) / Dennis Westermann | | 1.34.0 | 2026-08-10 | D-105 aufgenommen: Dennis Westermann ist alleiniger Projektinhaber, Tier-Entscheider und Mergeberechtigter; Tier 2 bleibt mit CLA und aktueller Inhaberfreigabe aktiv, Inhaber-PRs dürfen nach grüner Pflicht-CI und unabhängigem Review selbst gemergt werden, und manuelle Spielabnahme darf ehrlich zurückgestellt, aber nicht als gelaufen behauptet werden | Project Owner / Orchestrator | | 1.33.0 | 2026-08-09 | D-101 aufgenommen: der Ausgangspin der kanonischen KI-Partie (Entscheidungstick, Endzustand) wird vom Identitätspin getrennt und zieht in eine Maintainer-Datei; `tools/Nova.SimRunner.Tests/` bekommt erstmals eine Eigentümerzeile | Project Owner / Orchestrator | | 1.0.0 | 2026-07-21 | D-001 bis D-005 aus Sprint 0 protokolliert | Game Director | @@ -3212,6 +3301,6 @@ gespielt gemeldet werden. | 1.27.0 | 2026-08-07 | D-089 aufgenommen: implementiertes 1v1-Lockstep über TCP, `TickComplete` als reiner Transport-Barrier, optionales Submission-Readiness-Gate, getrenntes `NOVAREC2`-/Diagnostikformat und fail-closed linux-x64-/systemd-/Deploy-Vertrag; D-033 hinsichtlich UDP und Ergebnisautorität teilweise ersetzt | Project Owner / Agent (Umsetzung) | | 1.28.0 | 2026-08-08 | D-090 aufgenommen: fog-sicheres sichtbares Gefechtsfeedback, D-039-konformer Tier-0-One-Shot-Service, 35 unveränderte Kenney-OGGs mit Batch-Provenienz, ehrlich unvollständige Suno-Nachweise und headless Quellcode-Guard; sämtliche Abweichungen vom 12B-Plan explizit begrenzt | Project Owner / Agent (Umsetzung) | | 1.29.0 | 2026-08-08 | D-091 aufgenommen: Tier 2 vor dem ersten externen PR aktiviert; PolyForm Noncommercial plus dokumentierte, nicht rückwirkende CLA für externe Beiträge, zwei Merge-Accounts, Maintainer-Peer-Review auf jedem PR sowie vertrauenswürdige metadata-only Review-/Baseline-Checks entschieden | Dennis Westermann | -| 1.30.0 | 2026-08-09 | D-095 bis D-097 aufgenommen (Sprint 14 Lobby): Vermittlung über Supabase Edge Functions mit schlankem engine-freiem HTTPS-Client, Polling und RLS deny-all; kurzlebige 64-bit-HMAC-Match-Tokens für den Relay (Single-Use über Resets, abgeleiteter Seed, statischer Direktweg unverändert); Build-Commit zur Laufzeit lesbar (Editor-Build-Stempel, `dev-editor`-Fallback). D-096-Richtung vom Inhaber vorgegeben, Ausformung sowie D-095/D-097 vom Agenten unter Delegation — überstimmbar | Agent (unter Delegation) / D-096 Richtung: Dennis Westermann | +| 1.30.0 | 2026-08-09 | D-092 bis D-094 aufgenommen (Sprint 14 Lobby): Vermittlung über Supabase Edge Functions mit schlankem engine-freiem HTTPS-Client, Polling und RLS deny-all; kurzlebige 64-bit-HMAC-Match-Tokens für den Relay (Single-Use über Resets, abgeleiteter Seed, statischer Direktweg unverändert); Build-Commit zur Laufzeit lesbar (Editor-Build-Stempel, `dev-editor`-Fallback). D-093-Richtung vom Inhaber vorgegeben, Ausformung sowie D-092/D-094 vom Agenten unter Delegation — überstimmbar | Agent (unter Delegation) / D-093 Richtung: Dennis Westermann | | 1.30.0 | 2026-08-09 | D-095, D-096 und D-097 aufgenommen: Parallelbetrieb trennt über Dateihoheit statt Verhaltensraum (Sprint 16 parallel zu 13B, `Simulation/State/` in eingefrorenes Layout und strangeigene Befehlsanwendung geteilt, ein Strang je Merge-Fenster); Lager erhält eine aus dem Gebäudebestand **abgeleitete** AE-Obergrenze statt eines Zustandsfeldes und das Radar schaltet die Minimap frei; „Stoppen" löscht zusätzlich `UnitState.AttackTarget`, das Halte-Feuer bleibt beim Einheitenstrang | Project Owner / Orchestrator | | 1.31.0 | 2026-08-09 | D-098 bis D-100 nachgetragen, nachdem sie anderswo bereits als geltend zitiert wurden: Tier-3-Auslöser auf Veröffentlichung, Geld und Publikum präzisiert statt auf jede personenbezogene Verarbeitung; Identitätsmodell der geschlossenen Beta mit Klartext-IP plus gekürztem Netzpräfix und 30-Tage-Löschfrist statt `HMAC(pepper, ip)`, MAC-Adresse verworfen, IP- und Präfixsperren zwingend befristet; Lobby-Serverseite als Quelltext unter `tools/lobby/` statt nur im Supabase-Projekt (vom Agenten unter Delegation entschieden, überstimmbar, in den Offenen Punkten vermerkt). D-095 Punkt 3 im Wortlaut auf das Regelwerk nachgezogen — die Befehlsanwendung trennt zwischen Zielsetzung und Ausführung, nicht zwischen Befehlsarten; dieselbe Entscheidung, keine neue D-ID. Kopfzeile von Sprint 13.0 auf 16 berichtigt | Project Owner / Agent (unter Delegation) | diff --git a/docs/production/MVPContentManifest.md b/docs/production/MVPContentManifest.md index cd59dd8..bd8d684 100644 --- a/docs/production/MVPContentManifest.md +++ b/docs/production/MVPContentManifest.md @@ -1,6 +1,6 @@ # MVP-Inhaltsmanifest MS-1 -**Version:** 1.1.1 | **Status:** verbindlich – G0-A aktiv, Content gesperrt | **Verantwortungsbereich:** Game Director / Producer / Lead Technical Director | **Sprint:** 7 +**Version:** 1.2.0 | **Status:** verbindliche Inhaltsgrenze – Gate-Kette unter Tier 2 schlafend | **Verantwortungsbereich:** Game Director / Producer / Lead Technical Director | **Sprint:** 16 ## Zweck @@ -9,12 +9,14 @@ MVP als abhängigen, spielbaren Kern statt als Sammlung isolierter Features. Die maschinenlesbare Quelle ist [`../../quality/content/mvp-v1.json`](../../quality/content/mvp-v1.json); bei einer Abweichung muss beides in derselben Änderung korrigiert werden. Das Manifest ist -eine Anforderung, kein Nachweis: G0, MS-0 und MS-1 sind offen. +eine Anforderung, kein Nachweis. Der in der JSON-Datei erhaltene G0-Stand ist +die schlafende Tier-3-Ausgangslage; unter Tier 2 führt er keinen aktiven Gate- +oder Meilensteinstatus (D-076/D-105). ## Abhängigkeiten - [DecisionLog.md](DecisionLog.md) – D-056 (Closed-Core MS-1), D-058 - (Kapazitäten) und D-061 (Abnahme) + (Kapazitäten), D-061 (Abnahme) und D-077 (spielbares Opening) - [MVPRecoveryPlan.md](MVPRecoveryPlan.md) – Gates G0 bis G5 - [../gamedesign/Buildings.md](../gamedesign/Buildings.md), [../gamedesign/Infantry.md](../gamedesign/Infantry.md) und @@ -49,18 +51,19 @@ Versprechen. ## 2. Startzustand -Jede Seite beginnt mit: +Jede Seite beginnt gemäß D-077 und der maschinenlesbaren Quelle mit: - einem fertiggestellten HQ, -- einer fertiggestellten Raffinerie, -- einem Builder, -- zwei Harvestern und -- 1.000 AE. +- einem Builder und +- 3.000 AE. -Die Start-Raffinerie ist die **einzige** Ausnahme von ihren normalen -Bauvoraussetzungen. Sie erzeugt beim Matchstart keinen zusätzlichen Harvester. -Alle weiteren Gebäude und Einheiten folgen den regulären Voraussetzungen der -führenden GDD-Dokumente, soweit dieses Manifest sie nicht für MS-1 überschreibt. +Raffinerie und Harvester sind nicht vorplatziert. Die Raffinerie hat in MS-1 +kein Kraftwerk-Prerequisite und produziert die Harvester; ihr normaler +Energiebedarf bleibt bestehen. Alle weiteren Gebäude und Einheiten folgen den +regulären Voraussetzungen der führenden GDD-Dokumente, soweit dieses Manifest +sie nicht für MS-1 überschreibt. Die detaillierte AE-Kontoregel bleibt bewusst +in [../gamedesign/Economy.md](../gamedesign/Economy.md); sie ist kein Feld des +maschinenlesbaren Inhaltsmanifests. ## 3. Gebäudeumfang @@ -216,14 +219,17 @@ Vollspiel-Zielbild, werden für MS-1 jedoch durch D-056 übersteuert. - Q-018 und Q-019 bleiben offen, blockieren MS-1 aber nicht. - Balancing-Werte außerhalb der ausdrücklich überschriebenen Cargo-Kapazität - bleiben Tuningwerte ihrer führenden GDD-Dokumente und werden über G5 - abgenommen. + bleiben Tuningwerte ihrer führenden GDD-Dokumente und werden in gespielten + Runden abgestimmt; G5 wird erst nach einer Tier-3-Reaktivierung wieder Gate. ## Nächste Schritte -1. G0-A und G0-B ohne Inhaltsvorgriff herstellen. -2. Definitionen erst nach bestandenem G1 gegen dieses Manifest implementieren. -3. Ab G4 die exakte Manifestvollständigkeit und Provenienz nachweisen. +1. Menschliche und maschinenlesbare Inhaltsgrenze bei jeder Änderung gemeinsam + halten. +2. Aktive Pakete über PR, strikte CI und ehrlich dokumentierte Spielabnahme + integrieren. +3. Bei einer späteren Tier-3-Reaktivierung den erhaltenen G0-Stand prüfen und + die Gate-Kette nach GOVERNANCE.md ausdrücklich aufwecken. ## Änderungsverlauf @@ -232,3 +238,4 @@ Vollspiel-Zielbild, werden für MS-1 jedoch durch D-056 übersteuert. | 1.0.0 | 2026-07-24 | Verbindlichen dependency-closed MS-1-Umfang gemäß D-056 und D-058 festgelegt | Game Director / Producer / Lead Technical Director | | 1.1.0 | 2026-07-24 | Sieg-, Remis-, Zeitlimit- und Last-Unit-Reveal-Vertrag geschlossen | Game Director / Lead Technical Director | | 1.1.1 | 2026-07-24 | Aktiven Sprint-7-Status auf G0-A/G0-B korrigiert; Content bleibt bis nach G1 gesperrt | Game Director / Producer / Lead Technical Director | +| 1.2.0 | 2026-08-10 | Menschliche Startzustandsbeschreibung an die seit D-077 bindende JSON-Quelle angeglichen (HQ + Builder + 3.000 AE, Raffinerie/Harvester nicht vorplatziert) und den G0-Status als schlafende Tier-3-Ausgangslage statt aktives Tier-2-Gate klargestellt | Codex / Dennis Westermann | diff --git a/docs/production/hashkrieg/16_Sprint_Wirtschaft.md b/docs/production/hashkrieg/16_Sprint_Wirtschaft.md index 5a2499f..1f0a0da 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.1.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.2.0 | **Status:** in Umsetzung | **Verantwortungsbereich:** Netzstrang (Maintainer) | **Sprint:** 16 | **Vorgänger:** [12_Sprint_Zu_Zweit.md](12_Sprint_Zu_Zweit.md) Strang C | **Parallel zu:** [13B](13B_Sprint_Einheitenverhalten.md) | **Regelwerk:** [13-15_Parallelbetrieb.md](13-15_Parallelbetrieb.md) | **UX-Gate:** human | **Leitsatz:** ein Gebäude, das Strom zieht und nichts tut, ist kein Platzhalter, sondern ein Schaden ## Zweck @@ -158,8 +158,18 @@ Bauvoraussetzungen bleiben korrekt. ### 16.4 · Das Lager wird ein Gebäude (#53, C2) -AE-Obergrenze im `EconomySystem`: **HQ 2.000 AE Basis, +2.000 je Lager, -Überschuss verfällt, 25 % Verlust bei Zerstörung** (D-024). +AE-Obergrenze im `EconomySystem`: **genau eine HQ-Kontobasis von 2.000 AE, +sobald mindestens ein fertiges HQ lebt; +2.000 je fertigem Lager** +(D-024/D-096/D-106). Baustellen zählen nicht. Jede neue Einzahlung und +Rückerstattung wird sofort an der aktuellen Grenze gekappt; der Rest verfällt. + +Ein bereits vorhandener Bestand oberhalb der Grenze verliert alle 10 +Simulationsticks (1 s) **25 % des aktuellen Überhangs**, ganzzahlig abgerundet +und mindestens 1 AE, bis die Grenze erreicht ist. Das gilt für den +3.000-AE-Start, Restore sowie die Grenzsenkung durch Zerstörung oder Verkauf +eines Lagers und den Verlust des letzten HQ. Es gibt keinen zusätzlichen +Einmalverlust. Beim Verkauf wird die Rückerstattung noch gegen die vor dem +Despawn geltende Kapazität gedeckelt; danach sinkt die Grenze. > **Die Kapazität wird aus dem Gebäudebestand abgeleitet, nicht gespeichert.** > Ein neues Feld im Wirtschaftszustand bumpt `EconomySystem.StateVersion`, und @@ -168,9 +178,16 @@ AE-Obergrenze im `EconomySystem`: **HQ 2.000 AE Basis, +2.000 je Lager, > nachziehen muss, wäre eine eigene Inhaberentscheidung. Die abgeleitete Variante > kostet nichts davon. -`AddCredits` hat genau vier Aufrufer, alle in diesem Sprintbereich: Abladen -(`EconomySystem`), Streichung (`ProductionSystem`), Abbruch und Verkauf -(`ConstructionSystem`). +Die Zustandsbytes bleiben kompatibel; die **Regelidentität** ändert sich aber. +`RulesHash64` bindet deshalb Revision 1 und die Werte 2.000/2.000/25/10. Alte +Replay-/Snapshot-Dateien bleiben strukturell lesbar, doch eine Replay-Wiedergabe +unter einem anderen Rules-Hash wird vor Tick 1 abgelehnt. So können alte und +neue Peers nicht erst am ersten Zerfallstick desynchronisieren (D-106). + +`DepositCapped` bündelt genau vier produktive Gutschriftpfade in diesem +Sprintbereich: Abladen (`EconomySystem`), Streichung (`ProductionSystem`), +Abbruch und Verkauf (`ConstructionSystem`). Nur dieser Helfer ruft produktiv +`PlayerEconomyState.AddCredits` auf. ### 16.5 · Das Radar wird ein Gebäude (#54, C3) @@ -345,8 +362,9 @@ dann **16.6**. Jeder Abwurf mit Begründung in den |---|---|---| | D-096 | Lager erhält eine **abgeleitete** AE-Obergrenze (kein Zustandsfeld); Radar schaltet die Minimap frei und leitet seine Abdeckung vom Gebäude ab | Inhaber (Richtung) / Agent (Ausformung) | | D-097 | „Stoppen" löscht den Angriffsbefehl; ein Halte-Feuer bleibt beim Einheitenstrang | Inhaber | +| D-106 | AE-Kontobasis gilt einmalig je Slot; vorhandener Überhang zerfällt zustandslos pro Sekunde und die Regelrevision wird im Match-Fingerprint gebunden | Agent unter Inhaberdelegation | -D-096 und D-097 sind im [DecisionLog](../DecisionLog.md) eingetragen. D-098 +D-096, D-097 und D-106 sind im [DecisionLog](../DecisionLog.md) eingetragen. D-098 (Entwurf) und D-099 stehen dort für [Sprint 17](17_Sprint_Zugangsprotokoll.md), D-100 bleibt für dessen Paket B vorgemerkt, D-098 gehört zu [Sprint 14](14_Sprint_Lobby.md). Keine dieser Nummern darf hier verbraucht @@ -370,5 +388,6 @@ Die Baseline-Neusetzung ist Zweck der Tests, kein Bruch. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.2.0 | 2026-08-10 | D-106 für 16.4 festgeschrieben: einmalige HQ-Kontobasis, periodischer 25-%-Abbau des aktuellen Überhangs und Rules-Hash-Kompatibilitätsgrenze | Codex / Dennis Westermann | | 1.1.0 | 2026-08-10 | D-105-Integrationsausnahme für 16.3 dokumentiert: aktive Sites sind keine Kampfteilnehmer oder fertigen KI-Produzenten | Codex / Dennis Westermann | | 1.0.0 | 2026-08-09 | Erstfassung: Strang C aus Sprint 12 und die acht Betatest-Befunde im selben Schreibbereich zu einem Sprint zusammengeführt, am Code geprüft und nach Kosten sortiert | Orchestrator | diff --git a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs index 7908432..ad99ff1 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs @@ -48,12 +48,12 @@ public sealed class CanonicalAiOutcomeTests /// /// End-state hash of the canonical AI match, last moved by: Sprint 16 - /// package 16.2 (#46) — produced units spawn at the building footprint - /// and walk to their rally point. The AI itself is unchanged: - /// AiBehaviorId stayed r5.779A1B5B. - /// Previous value: 0x8C0B54F31F2986B7 (Sprint 16.1). + /// package 16.4 (#53) — storage capacity and excess decay change the + /// economy the AI plays in. The AI itself is unchanged: + /// AiBehaviorId stays r6.E34435F9. + /// Previous value: 0x9F93097AD526B6F7 (Sprint 16.2). /// - private const string PinnedEndState = "0x9F93097AD526B6F7"; + private const string PinnedEndState = "0xE784E6184AD16081"; [Test] public void CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState() diff --git a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs index 1464f72..fcb6cd5 100644 --- a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs @@ -670,7 +670,7 @@ public MatchFingerprint CreateFingerprint() slots[HumanSlot] = (byte)PlayerSlotOccupancy.Human; slots[AiSlot] = (byte)PlayerSlotOccupancy.AI; return MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots, diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index bbda9a3..49919ec 100644 --- a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs @@ -696,7 +696,8 @@ public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged( public void CancelConstruction_Refunds75Percent_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and 2,000 AE capacity"); f.SpawnBuilder(0, 19, 20); f.Step(1); // commit the balance Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 500 spent @@ -719,7 +720,8 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint() public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and 2,000 AE capacity"); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); @@ -730,13 +732,51 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() Assert.That(f.Construction.IsCellFree(20, 20), Is.True); f.SpawnBuilder(0, 19, 20); - f.Step(1); // commit the balance (100 provided, 0 required) + f.Step(1); // commit the balance (30 provided, 0 required) Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); Assert.That(f.Construction.ValidateSell(0, siteRaw), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), "a site is cancelled, not sold"); } + [Test] + public void CancelConstruction_RefundIsCappedAtStorageCeiling() + { + var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and the 2,000 AE ceiling"); + f.SpawnBuilder(0, 19, 20); + f.Step(1); + Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 2.000 - 500 = 1.500 + uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); + f.Economy.GetPlayerEconomy(0).AddCredits(495); // raw fixture setup: 1.995 + + Assert.That(f.Construction.CancelConstruction(siteRaw), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 375 AE refund fit; the overflow is forfeit"); + } + + [Test] + public void SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay() + { + var f = new Fixture(startingCredits: 3900); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); + EntityId storage = f.Construction.PlaceCompletedBuilding(0, 6, 20, 20); + Assert.That(f.Economy.CapacityFor(0), + Is.EqualTo(EconomySystem.HqBaseCapacityAE + EconomySystem.StorageCapacityBonusAE)); + + Assert.That(f.Construction.SellBuilding(UnitCommandStateView.ToRawEntityId(storage)), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(4000L), + "only 100 of the 150 AE sale refund fit before the Storage leaves the stock"); + Assert.That(f.Economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "selling the Storage immediately lowers the derived ceiling"); + + f.Step(EconomySystem.ExcessDecayIntervalTicks); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3500L), + "tick 10 removes 25% of the 2,000 AE excess"); + } + [Test] public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { diff --git a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs index 1a4aad1..79e133e 100644 --- a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs @@ -120,6 +120,11 @@ public void RestoreSessionTick() new Transform2D(SimFixed.FromInt(11), SimFixed.FromInt(10)), SimFixed.Zero, role: UnitRole.Refinery); + Entities.SpawnUnit( + owner, + new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), + SimFixed.Zero, + role: UnitRole.HQ); return (UnitCommandStateView.ToRawEntityId(harvester), harvester); } @@ -318,7 +323,7 @@ public void Replay_HarvestAndReturnIntents_PlaybackReproducesEndHash() slots[0] = (byte)PlayerSlotOccupancy.Human; slots[1] = (byte)PlayerSlotOccupancy.AI; MatchFingerprint fingerprint = MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots, @@ -483,7 +488,7 @@ public void Replay_WithHarvestRejections_PlaybackReproducesResultsAndEndHash() slots[0] = (byte)PlayerSlotOccupancy.Human; slots[1] = (byte)PlayerSlotOccupancy.AI; MatchFingerprint fingerprint = MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots, diff --git a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs index 7118c59..c738eea 100644 --- a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs @@ -163,6 +163,11 @@ public void HarvestCycle_GathersExactRate_AndDepositRaisesCreditsExactly() kernel.Start(); Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + // 16.4: deposits obey the derived storage ceiling — a completed + // HQ provides the 2.000 AE base. Far away, so no reach rule here + // is touched. + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, role: UnitRole.HQ); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); entities.GetUnitRef(harvester).HarvestFieldId = 1; @@ -186,6 +191,33 @@ public void HarvestCycle_GathersExactRate_AndDepositRaisesCreditsExactly() "credits rise by exactly the cargo"); } + [Test] + public void HarvesterDeposit_OverflowIsForfeitAtTheStorageCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: 1995); + kernel.RegisterSystem(economy); + kernel.Start(); + + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, + role: UnitRole.HQ); + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(11), SimFixed.FromInt(10)), SimFixed.Zero, + role: UnitRole.Refinery); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); + ref UnitState unit = ref entities.GetUnitRef(harvester); + unit.CargoAE = 10; + unit.IsReturningCargo = true; + + kernel.StepTick(); + + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 10 AE cargo fit below the HQ ceiling"); + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(0), + "overflow is forfeit, so the full cargo leaves the Harvester"); + Assert.That(entities.GetUnitRef(harvester).IsReturningCargo, Is.False); + } + [Test] public void ReturnOrder_RefineryFootprintEdgeInReach_DepositsWithCentreTwoCellsAway() { @@ -208,6 +240,11 @@ public void ReturnOrder_RefineryFootprintEdgeInReach_DepositsWithCentreTwoCellsA 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 8, 4); Assert.That(refinery.IsValid, Is.True); + // 16.4: the deposit obeys the derived ceiling — completed HQ, + // far away so no reach rule here is touched. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + // Adjacent to the footprint's west edge cell (8,6), Chebyshev 2 // from the centre (9,5). EntityId harvester = SpawnHarvester(entities, 0, 7, 6); @@ -239,6 +276,10 @@ public void AutoCycle_CanonicalOpeningDistances_CompletesRoundTripAndResumes() Assert.That(economy.TryAddField(1, new GridPos2D(7, 7), 9000), Is.True); construction.PlaceCompletedBuilding( 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 8, 4); + // 16.4: deposits obey the derived ceiling — completed HQ, far + // away so the opening geometry under test is untouched. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); EntityId harvester = SpawnHarvester(entities, 0, 7, 6); entities.GetUnitRef(harvester).HarvestFieldId = 1; @@ -426,6 +467,200 @@ public void TryAddField_ValidatesIdentityAndReserve() Assert.That(economy.FieldCount, Is.EqualTo(1)); } + // ------------------------------------------------------------------ + // 16.4 (#53, D-024/D-096/D-106): the derived AE ceiling + // ------------------------------------------------------------------ + + [Test] + public void DepositCapped_ClampsAtTheDerivedCeiling_OverflowIsForfeit() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), "one completed HQ: the 2.000 AE base"); + + Assert.That(economy.DepositCapped(0, 1500), Is.EqualTo(1000L), + "only what fits under the ceiling lands"); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2000L), + "1000 start + 1000 that fit — the remaining 500 are forfeit"); + Assert.That(economy.DepositCapped(0, 500), Is.EqualTo(0L), "at the ceiling nothing more lands"); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2000L)); + Assert.That(economy.CapacityFor(1), Is.EqualTo(0L), "no buildings, no ceiling — the other slot is unaffected"); + } + + [Test] + public void CapacityFor_CountsCompletedStorage_AndExcludesSites() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: 3000); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + kernel.StepTick(); // commit the grid (30 provided) for the placement power rule + + // A storage SITE holds nothing yet. + Assert.That(construction.TryPlaceBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 20, 20), Is.True, + "storage site placed (cost fits the 3.000 start)"); + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "an unfinished silo holds nothing"); + + // A COMPLETED storage adds its 2.000. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 50, 50).IsValid, Is.True); + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE + EconomySystem.StorageCapacityBonusAE), + "HQ base + one completed storage"); + } + + [Test] + public void CapacityFor_MultipleCompletedHqs_ProvideOneAccountBase() + { + EntityManager entities = CreateEntities(); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 20, 20).IsValid, Is.True); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 50, 50).IsValid, Is.True); + + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "the HQ capacity is one account base, not a bonus per HQ"); + } + + [Test] + public void CapacityFor_HqSiteAlone_ProvidesNoAccountBase() + { + EntityManager entities = CreateEntities(); + var economy = new EconomySystem(entities, startingCredits: 3000); + var construction = new ConstructionSystem(entities, economy); + + Assert.That(construction.TryPlaceBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 20, 20), Is.True); + Assert.That(economy.CapacityFor(0), Is.EqualTo(0L), + "an unfinished HQ-role site is not a completed HQ"); + } + + [Test] + public void CapacityAndDeposit_InvalidSlot_ReturnZeroWithoutMutation() + { + var economy = new EconomySystem(CreateEntities()); + + Assert.That(economy.CapacityFor(byte.MaxValue), Is.EqualTo(0L)); + Assert.That(economy.DepositCapped(byte.MaxValue, 500L), Is.EqualTo(0L)); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1000L)); + } + + [Test] + public void DecayExcessBalance_QuarterPerSecond_ConvergesToTheCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + economy.GetPlayerEconomy(0).AddCredits(2000); // raw write: 3.000 total, 1.000 over the 2.000 ceiling + for (int i = 0; i < 9; i++) kernel.StepTick(); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3000L), + "no decay between the per-second decay ticks"); + + kernel.StepTick(); // tick 10: first decay — 25% of the 1.000 excess + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2750L)); + + for (int i = 0; i < 10; i++) kernel.StepTick(); // tick 20: 25% of 750 (floor 187) + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2563L), + "integer floor decay, once per second"); + + for (int i = 0; i < 80; i++) kernel.StepTick(); // tick 100: converging, minimum-1-AE steps + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2058L)); + } + + [Test] + public void DecayExcessBalance_NeverTouchesBalancesAtOrBelowTheCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + for (int i = 0; i < 25; i++) kernel.StepTick(); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1000L), + "1.000 under the 2.000 ceiling: the decay never runs"); + + // Without any building the ceiling is zero and even the start + // stock decays — the no-HQ path defined by D-106. + var lone = new EconomySystem(CreateEntities()); + var loneKernel = new SimulationKernel(new SimRandom(42UL)); + loneKernel.RegisterSystem(lone); + loneKernel.Start(); + Assert.That(lone.DepositCapped(0, 500), Is.EqualTo(0L), "no ceiling, no deposit"); + for (int i = 0; i < 10; i++) loneKernel.StepTick(); + Assert.That(lone.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(750L), + "no HQ and no storage: the 1.000 start decays (excess 1.000 over ceiling 0)"); + } + + [Test] + public void DestroyedStorage_LowersCapacity_AndStartsExcessDecay() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: 3900); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + EntityId storage = construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 20, 20); + Assert.That(storage.IsValid, Is.True); + Assert.That(economy.CapacityFor(0), + Is.EqualTo(EconomySystem.HqBaseCapacityAE + EconomySystem.StorageCapacityBonusAE)); + + Assert.That(entities.DespawnUnit(storage), Is.True, "combat destruction despawns the Storage entity"); + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE)); + for (int i = 0; i < EconomySystem.ExcessDecayIntervalTicks; i++) kernel.StepTick(); + + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3425L), + "25% of the new 1.900 AE excess decays at tick 10"); + } + + [Test] + public void DecayExcessBalance_LongMaxValue_DoesNotOverflow() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: long.MaxValue); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + for (int i = 0; i < 10; i++) kernel.StepTick(); + + long excess = long.MaxValue - EconomySystem.HqBaseCapacityAE; + long expectedLoss = excess / 4L; + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(long.MaxValue - expectedLoss)); + } + private static byte[] SerializeBlock(EconomySystem economy) { var writer = new SnapshotBlockWriter(); diff --git a/tools/Nova.SimRunner.Tests/HarvesterAutoCycleTests.cs b/tools/Nova.SimRunner.Tests/HarvesterAutoCycleTests.cs index 07dfae9..441ac83 100644 --- a/tools/Nova.SimRunner.Tests/HarvesterAutoCycleTests.cs +++ b/tools/Nova.SimRunner.Tests/HarvesterAutoCycleTests.cs @@ -22,7 +22,16 @@ namespace Nova.SimRunner.Tests [TestFixture] public sealed class HarvesterAutoCycleTests { - private static EntityManager CreateEntities() => new EntityManager(64); + private static EntityManager CreateEntities() + { + var entities = new EntityManager(64); + entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), + SimFixed.Zero, + role: UnitRole.HQ); + return entities; + } private static EntityId SpawnHarvester(EntityManager entities, byte player, int x, int y) { diff --git a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs index 2b17aa2..ee0cdd2 100644 --- a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs +++ b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs @@ -446,7 +446,7 @@ public MatchFingerprint CreateFingerprint() // static or lobby-derived (D-093) — never a test constant. ulong offeredSeed = Client != null ? Client.Seed : Seed; return MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), SimDefinitions.ComputeDefinitionsHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots, factions, offeredSeed, Kernel.CalculateStateHash(), Session.InputDelayTicks); @@ -1142,6 +1142,39 @@ public void FingerprintMismatch_RefusesTheMatch_AndNamesTheField() server.Stop(); } + [Test] + public void LegacyEmptyRulesFingerprint_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 legacy = MatchFingerprint.CreateCurrent( + MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + current.DefinitionsHash64, current.MapHash64, + current.GetSlotOccupancyCopy(), current.GetSlotFactionCopy(), + current.StartSeed, current.InitialStateHash, current.InputDelayTicks); + + clientA.SubmitLocalProof(current.Serialize(), hostA.Kernel.SaveSnapshot()); + clientB.SubmitLocalProof(legacy.Serialize(), hostB.Kernel.SaveSnapshot()); + PumpUntil(server, clientA, clientB, + () => clientA.Phase == RelayClientPhase.Ended && clientB.Phase == RelayClientPhase.Ended, + "the relay refused the old/new 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() { @@ -1964,8 +1997,9 @@ public void Desync_WritesOneParseableSnapshotAndRecordStreamPerClient() // drive helper permits the normal input-delay pipeline lead, so // aiming at 49 could already have crossed tick 50 on one end. Drive(server, clientA, clientB, hostA, hostB, 25); - ref PlayerEconomyState divergentEconomy = ref hostB.Economy.GetPlayerEconomy(0); - divergentEconomy.AddCredits(1); + ref UnitState divergentBuilder = ref hostB.Entities.GetUnitRef( + UnitCommandStateView.ToEntityId(hostB.BuilderRaw)); + divergentBuilder.CurrentHealth -= 1; Drive(server, clientA, clientB, hostA, hostB, 50); PumpUntil(server, clientA, clientB, () => clientA.Phase == RelayClientPhase.Ended && clientB.Phase == RelayClientPhase.Ended, diff --git a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs index 7dcb523..e4889a7 100644 --- a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs +++ b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs @@ -18,7 +18,7 @@ public sealed class MatchFingerprintTests private static MatchFingerprint CreateStandard() { return MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), ReplayTestUtil.StandardSlots(), @@ -66,14 +66,17 @@ public void ComputeHash_IsStableAcrossInstances_AndStubHashesAreDistinct() Assert.That(CreateStandard().ComputeHash(), Is.EqualTo(CreateStandard().ComputeHash()), "identical fingerprints must hash identically"); - ulong rules = MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules); + ulong rules = MatchFingerprint.ComputeCurrentRulesHash64(); ulong definitions = MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions); ulong map = MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map); Assert.That(definitions, Is.Not.EqualTo(rules)); Assert.That(map, Is.Not.EqualTo(rules)); Assert.That(map, Is.Not.EqualTo(definitions)); - Assert.That(MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), Is.EqualTo(rules), - "stub hashes must be deterministic"); + Assert.That(MatchFingerprint.ComputeCurrentRulesHash64(), Is.EqualTo(rules), + "the current rules hash must be deterministic"); + Assert.That(rules, + Is.Not.EqualTo(MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules)), + "D-106 rules must not match the legacy empty rules stub"); } [Test] diff --git a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs index bde945b..c0075c1 100644 --- a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs @@ -470,7 +470,7 @@ public void Replay_ConstructionAndProductionIntents_PlaybackReproducesEndHash() slots[0] = (byte)PlayerSlotOccupancy.Human; slots[1] = (byte)PlayerSlotOccupancy.AI; MatchFingerprint fingerprint = MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots, diff --git a/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs b/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs index 228c555..f25f2e8 100644 --- a/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs @@ -51,15 +51,16 @@ public Fixture(long startingCredits = 1000, int capacity = 64, System.Action /// Places a completed Barracks at (10,10) and returns its raw wire - /// id. Also places a completed Power plant at (40,40) unless + /// id. Also places a completed HQ at (40,40) unless /// is false — a Barracks draws 15, - /// so a powered grid keeps production at full speed. + /// so the HQ keeps production at full speed and provides the + /// canonical 2,000 AE storage capacity used by refund tests. /// public uint SpawnBarracks(byte slot, bool withPower = true) { if (withPower) { - Assert.That(Construction.PlaceCompletedBuilding(slot, 5, 40, 40).IsValid, Is.True); + Assert.That(Construction.PlaceCompletedBuilding(slot, 3, 40, 40).IsValid, Is.True); } EntityId id = Construction.PlaceCompletedBuilding(slot, 7, 10, 10); Assert.That(id.IsValid, Is.True); @@ -308,6 +309,20 @@ public void CancelProduction_QueuedEntry_FullRefund_RunningEntryUntouched() Assert.That(remaining, Is.EqualTo((ushort)1), "the running entry is untouched"); } + [Test] + public void CancelProduction_RefundIsCappedAtStorageCeiling() + { + var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE); + uint barracks = f.SpawnBarracks(0); + Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); // 2.000 - 120 = 1.880 + f.Economy.GetPlayerEconomy(0).AddCredits(115); // raw fixture setup: 1.995 + + Assert.That(f.Production.CancelProduction(barracks, 0), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 120 AE refund fit; the overflow is forfeit"); + } + [Test] public void EntityStoreFull_QueuePauses_ResumesAfterSpace() { diff --git a/tools/Nova.SimRunner.Tests/ReplayTestUtil.cs b/tools/Nova.SimRunner.Tests/ReplayTestUtil.cs index f5d4ed8..8c5899c 100644 --- a/tools/Nova.SimRunner.Tests/ReplayTestUtil.cs +++ b/tools/Nova.SimRunner.Tests/ReplayTestUtil.cs @@ -182,7 +182,7 @@ internal static byte[] StandardFactions() internal static MatchFingerprint CreateFingerprint(TestHost host, ulong seed, byte[] slots = null) { return MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots ?? StandardSlots(), diff --git a/tools/Nova.SimRunner.Tests/ReplayTests.cs b/tools/Nova.SimRunner.Tests/ReplayTests.cs index 8cc2daa..8f884d4 100644 --- a/tools/Nova.SimRunner.Tests/ReplayTests.cs +++ b/tools/Nova.SimRunner.Tests/ReplayTests.cs @@ -177,6 +177,29 @@ public void FingerprintMismatch_DifferentStartSeed_RefusesPlayback() "a refused start must not touch the kernel"); } + [Test] + public void FingerprintMismatch_LegacyEmptyRules_RefusesPlaybackBeforeTickOne() + { + ReplayTestUtil.LiveMatch live = ReplayTestUtil.RunLiveMatch(); + MatchFingerprint legacyRules = MatchFingerprint.CreateCurrent( + MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + 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, legacyRules, 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), + "an old/new rules mismatch 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 65172ae..7b9f963 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs @@ -585,6 +585,14 @@ public void SkirmishAi_IgnoresDefinitionRoleHqSite_AndTargetsLegalEnemy() Assert.That(TryFirstCombatCell(host, AiSlot, out int armyX, out int armyY), Is.True); List army = CombatUnitIds(host, AiSlot); + // 16.4: the passive slot has decayed to its 2.000-AE HQ ceiling, + // below the 2.500-AE HQ-site cost. A completed Storage raises the + // derived cap and the capped deposit funds this focused fixture. + ushort storageDefId = SimDefinitions.ToDefinitionId( + host.Economy.GetSlotFaction(HumanSlot), UnitRole.Storage); + Assert.That(host.Construction.PlaceCompletedBuilding(HumanSlot, storageDefId, 60, 60).IsValid, Is.True); + Assert.That(host.Economy.DepositCapped(HumanSlot, 1000), Is.EqualTo(1000)); + EntityId hqSite = PlaceEnemySiteNear(host, UnitRole.HQ, armyX + 3, armyY); EntityId legalTarget = SpawnEnemyUnit(host, UnitRole.BattleTank, armyX + 3, armyY + 1); RunToDecisionWithSquad(host, SquadThreshold); diff --git a/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs b/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs index 6b4d70b..0430aff 100644 --- a/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs +++ b/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs @@ -220,7 +220,7 @@ public void DefinitionsHash64_IsStable_CoversBothFactions_AndIsNotAStub() "the same table must hash identically every time"); Assert.That(hash, Is.Not.EqualTo(MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Definitions)), "the real table hash replaces the empty-content stub"); - Assert.That(hash, Is.Not.EqualTo(MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules))); + Assert.That(hash, Is.Not.EqualTo(MatchFingerprint.ComputeCurrentRulesHash64())); Assert.That(hash, Is.Not.EqualTo(MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map))); // Row coverage: mutating ANY single row — first Alliance, first diff --git a/tools/Nova.SimRunner/Determinism10000Scenario.cs b/tools/Nova.SimRunner/Determinism10000Scenario.cs index 689040d..3ce8cd2 100644 --- a/tools/Nova.SimRunner/Determinism10000Scenario.cs +++ b/tools/Nova.SimRunner/Determinism10000Scenario.cs @@ -709,7 +709,7 @@ private static Host BuildHost(ulong seed, INovaLogger logger) /// /// The standard match configuration fingerprint: slot 0 - /// human/Alliance, slot 1 AI/Legion, stub rules/map hashes and the + /// human/Alliance, slot 1 AI/Legion, current rules hash, stub map hash and the /// REAL canonical definitions hash (SimDefinitions.ComputeDefinitionsHash64 /// — a replay recorded against a different definition table refuses /// to start, SimulationCore.md section 6). @@ -723,7 +723,7 @@ private static MatchFingerprint CreateFingerprint(Host host, ulong seed) factions[HumanSlot] = (byte)FactionId.Alliance; factions[AiSlot] = (byte)FactionId.Legion; return MatchFingerprint.CreateCurrent( - MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Rules), + MatchFingerprint.ComputeCurrentRulesHash64(), SimDefinitions.ComputeDefinitionsHash64(), MatchFingerprint.ComputeEmptyContentStubHash(MatchContentStub.Map), slots,