diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index 40f38eb..42abb37 100644 --- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs @@ -1032,14 +1032,14 @@ public void SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay() 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.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(4050L), + "the whole 150 AE sale refund now fits before the Storage leaves the stock; it only partially fit while the HQ ceiling was 2.000 (#131 raised it to 3.000)"); 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"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3788L), + "tick 10 removes 25% of the 1.050 AE excess (4.050 over the 3.000 HQ ceiling that #131 raised from 2.000; the excess used to be 2.000)"); } [Test] diff --git a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs index a567e3f..d28cf51 100644 --- a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs @@ -63,6 +63,31 @@ public void ParameterlessStartCredits_StayTheLibraryDefault() } } + [Test] + public void CanonicalStartBalance_FitsUnderTheFreshHqCeiling() + { + // #131 / E-2 (2026-08-31): the D-077 opening balance must never + // exceed the ceiling one fresh completed HQ provides. No test + // used to look at the two constants together — exactly why the + // 3.000 start over the 2.000 base survived three weeks and + // silently decayed a third of the opening buffer before the + // first harvester delivered. The named values are pinned too, + // so the next silent divergence between them trips here. + Assert.That(EconomySystem.CanonicalMatchStartingCreditsAE, Is.EqualTo(3000L), "D-077"); + Assert.That(EconomySystem.HqBaseCapacityAE, Is.EqualTo(3000L), + "E-2: the HQ account base carries the D-077 opening buffer"); + + EntityManager entities = CreateEntities(); + var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); + var construction = new ConstructionSystem(entities, economy); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + Assert.That(economy.CapacityFor(0), + Is.GreaterThanOrEqualTo(EconomySystem.CanonicalMatchStartingCreditsAE), + "the canonical start balance must fit under the fresh-HQ ceiling — no opening decay"); + } + [Test] public void Credits_NeverGoNegative_SpendingIsAtomic() { @@ -164,7 +189,7 @@ public void HarvestCycle_GathersExactRate_AndDepositRaisesCreditsExactly() 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 + // HQ provides the 3.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); @@ -196,7 +221,7 @@ public void HarvesterDeposit_OverflowIsForfeitAtTheStorageCeiling() { EntityManager entities = CreateEntities(); var kernel = new SimulationKernel(new SimRandom(42UL)); - var economy = new EconomySystem(entities, startingCredits: 1995); + var economy = new EconomySystem(entities, startingCredits: 2995); kernel.RegisterSystem(economy); kernel.Start(); @@ -310,6 +335,11 @@ public void Harvest_StopsAtCapacity_AndStartsTheReturnLeg() kernel.Start(); Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + // 16.4 / #136: the account must have room under its ceiling or + // the E-1 hold engages instead — a completed HQ far away + // provides the 3.000 AE base over the 1.000 start. + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, role: UnitRole.HQ); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); ref UnitState unit = ref entities.GetUnitRef(harvester); unit.HarvestFieldId = 1; @@ -348,6 +378,10 @@ public void Harvest_StopsAtFactionCapacity_Legion300_Alliance330() kernel.Start(); Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + // 16.4 / #136: account room under the ceiling, or the E-1 hold + // engages — a completed HQ far away provides it. + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, role: UnitRole.HQ); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); ref UnitState unit = ref entities.GetUnitRef(harvester); unit.HarvestFieldId = 1; @@ -374,6 +408,10 @@ public void FiniteField_CollectsOnlyRemainder_ThenStaysExhausted() kernel.Start(); Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 3), Is.True); + // 16.4 / #136: account room under the ceiling, or the E-1 hold + // engages — a completed HQ far away provides it. + 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; @@ -393,6 +431,138 @@ public void FiniteField_CollectsOnlyRemainder_ThenStaysExhausted() Assert.That(field.RemainingAE, Is.EqualTo(0L)); } + // ------------------------------------------------------------------ + // #136 (owner decision E-1 of 2026-08-31): the account-full hold — + // a harvester brakes at the PICKUP while its owner account has no + // room under the derived ceiling, and resumes by itself the moment + // room exists. The D-024 deposit forfeit is deliberately untouched. + // ------------------------------------------------------------------ + + [Test] + public void Harvest_AccountFull_HoldsWithoutMining_AndResumesWhenCapacityRises() + { + // The reported loop: storage full, harvester keeps driving, the + // delivery banks nothing, the finite reserve burns anyway. The + // pinned lifecycle: full account -> harvester rests, nothing is + // mined -> the ceiling rises -> the same order works again. + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: EconomySystem.HqBaseCapacityAE); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True, + "a completed HQ: the ceiling equals the start balance — the account is exactly full"); + + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); + entities.GetUnitRef(harvester).HarvestFieldId = 1; + + for (int i = 0; i < 20; i++) + { + kernel.StepTick(); + } + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(0), + "a full account: nothing is gathered"); + Assert.That(entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)1), + "the order is HELD, not dropped"); + Assert.That(entities.GetUnitRef(harvester).IsReturningCargo, Is.False); + Assert.That(economy.TryGetField(1, out AetheriumField field), Is.True); + Assert.That(field.RemainingAE, Is.EqualTo(9000L), + "the finite reserve is untouched — what was never mined is never lost"); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "the balance sits AT the ceiling, never above it — no decay either"); + + // The ceiling rises (a completed Storage) — the held order + // resumes without any command. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 50, 50).IsValid, Is.True); + kernel.StepTick(); + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(EconomySystem.HarvestRateAE), + "room under the ceiling: gathering resumed on its own"); + + for (int i = 0; i < 9; i++) + { + kernel.StepTick(); + } + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(10 * EconomySystem.HarvestRateAE)); + Assert.That(economy.TryGetField(1, out field), Is.True); + Assert.That(field.RemainingAE, Is.EqualTo(9000L - (10 * EconomySystem.HarvestRateAE))); + } + + [Test] + public void Harvest_AccountFull_HoldsFullCargo_UntilSpendingOpensRoom() + { + // The loop-break half of E-1: a harvester whose cargo is ALREADY + // full must not even start the return leg into a full account — + // the capped deposit would bank zero and still wipe the honestly + // gathered load. The load waits at the field until room exists + // (here: the player spends below the ceiling). + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: EconomySystem.HqBaseCapacityAE); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); + ref UnitState unit = ref entities.GetUnitRef(harvester); + unit.HarvestFieldId = 1; + unit.CargoAE = SimDefinitions.HarvesterCargoCapacityAE(FactionId.Alliance); // 330 — full + + for (int i = 0; i < 10; i++) + { + kernel.StepTick(); + } + unit = ref entities.GetUnitRef(harvester); + Assert.That(unit.CargoAE, Is.EqualTo(SimDefinitions.HarvesterCargoCapacityAE(FactionId.Alliance)), + "the full load is kept, not fed to a deposit that would bank zero"); + Assert.That(unit.IsReturningCargo, Is.False, + "no return leg starts while the account has no room"); + Assert.That(unit.HarvestFieldId, Is.EqualTo((ushort)1)); + Assert.That(economy.TryGetField(1, out AetheriumField field), Is.True); + Assert.That(field.RemainingAE, Is.EqualTo(9000L)); + + // The player spends below the ceiling — room opens and the held + // load starts its return leg on its own. + Assert.That(economy.GetPlayerEconomy(0).TrySpendCredits(500), Is.True); + kernel.StepTick(); + unit = ref entities.GetUnitRef(harvester); + Assert.That(unit.IsReturningCargo, Is.True, + "room under the ceiling: the held load starts its return leg by itself"); + Assert.That(unit.CargoAE, Is.EqualTo(SimDefinitions.HarvesterCargoCapacityAE(FactionId.Alliance)), + "the load survived the wait intact"); + } + + [Test] + public void Harvest_PartialAccountRoom_KeepsGathering() + { + // The E-1 boundary: "full" means NO room at all, not "less than + // a cargo". With a single AE of room the harvester keeps mining; + // how much of a delivered cargo then fits is the deposit's + // D-024 concern, deliberately unchanged. + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: EconomySystem.HqBaseCapacityAE - 1); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); + entities.GetUnitRef(harvester).HarvestFieldId = 1; + + kernel.StepTick(); + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(EconomySystem.HarvestRateAE), + "one AE of room is still room — only a completely full account holds the harvester"); + } + [Test] public void HarvestOrder_OutOfReach_IsHeldNotDropped() { @@ -483,14 +653,14 @@ public void DepositCapped_ClampsAtTheDerivedCeiling_OverflowIsForfeit() 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.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), "one completed HQ: the 3.000 AE base"); - Assert.That(economy.DepositCapped(0, 1500), Is.EqualTo(1000L), + Assert.That(economy.DepositCapped(0, 2500), Is.EqualTo(2000L), "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.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3000L), + "1000 start + 2000 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.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3000L)); Assert.That(economy.CapacityFor(1), Is.EqualTo(0L), "no buildings, no ceiling — the other slot is unaffected"); } @@ -578,20 +748,20 @@ public void DecayExcessBalance_QuarterPerSecond_ConvergesToTheCeiling() 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 + economy.GetPlayerEconomy(0).AddCredits(3000); // raw write: 4.000 total, 1.000 over the 3.000 ceiling for (int i = 0; i < 9; i++) kernel.StepTick(); - Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3000L), + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(4000L), "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)); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3750L)); 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), + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3563L), "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)); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3058L)); } [Test] @@ -608,7 +778,7 @@ public void DecayExcessBalance_NeverTouchesBalancesAtOrBelowTheCeiling() 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"); + "1.000 under the 3.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. @@ -627,7 +797,7 @@ public void DestroyedStorage_LowersCapacity_AndStartsExcessDecay() { EntityManager entities = CreateEntities(); var kernel = new SimulationKernel(new SimRandom(42UL)); - var economy = new EconomySystem(entities, startingCredits: 3900); + var economy = new EconomySystem(entities, startingCredits: 4900); var construction = new ConstructionSystem(entities, economy); kernel.RegisterSystem(economy); kernel.Start(); @@ -643,7 +813,7 @@ public void DestroyedStorage_LowersCapacity_AndStartsExcessDecay() 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), + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(4425L), "25% of the new 1.900 AE excess decays at tick 10"); } diff --git a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs index 22fe811..1c66f4c 100644 --- a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs +++ b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs @@ -84,8 +84,14 @@ public void RulesRevisionOneAndTwo_GoldenHashesRemainByteStable() ulong revisionOne = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV1); ulong revisionTwo = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2); - Assert.That(revisionOne, Is.EqualTo(0x531CE8F614A16CB5UL), "revision 1 canonical stream is frozen"); - Assert.That(revisionTwo, Is.EqualTo(0x07725EA26668C9F8UL), "revision 2 canonical stream is frozen"); + // NOT actually frozen — see issue #138. ComputeRulesHash64 writes + // TODAY's constants into every revision, so raising + // HqBaseCapacityAE (#131) moved the revision-1 hash too. Re-pinning + // here restores green; it does NOT restore the guarantee the name + // claims. Do not read this assertion as proof of anything until #138 + // is decided. + Assert.That(revisionOne, Is.EqualTo(0x1163158B8146B168UL), "revision 1 canonical stream is frozen"); + Assert.That(revisionTwo, Is.EqualTo(0x259E6977B751109BUL), "revision 2 canonical stream is frozen"); } [Test] @@ -96,7 +102,7 @@ public void CurrentRulesHash_MovesPastRevisionTwo_ForD104PlacementAndRepair() Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV3)); Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3))); - Assert.That(current, Is.EqualTo(0x05CCA8475789AD4AUL), "revision 3 canonical stream is frozen"); + Assert.That(current, Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen"); Assert.That(current, Is.Not.EqualTo(revisionTwo), "D-104 placement and repair behavior must not share revision 2's rules identity"); } diff --git a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs index b29591f..cd04151 100644 --- a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs +++ b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs @@ -37,7 +37,18 @@ namespace Nova.Simulation.Economy /// order whose grid cell is the /// field's cell or adjacent (Chebyshev distance <= 1, documented /// reach rule) gathers per tick, bounded by - /// the field's remaining reserve and the free cargo space. A FULL cargo + /// the field's remaining reserve and the free cargo space — but ONLY + /// while the owner account has room under the derived storage ceiling. + /// An account at or above its ceiling holds the harvester in place with + /// its order kept (#136, owner decision E-1 of 2026-08-31): every AE + /// gathered into a full account would be forfeited at the deposit while + /// the FINITE field (G2 reservation, D-010) still drains — the reserve + /// burns in front of a player who reads it as progress. The brake sits + /// at the pickup, not the deposit: what was never mined is never lost, + /// and the D-024 forfeit itself stays exactly as decided. The check + /// re-reads the live balance and the derived ceiling on every tick and + /// stores nothing, so the harvester resumes BY ITSELF the moment room + /// exists — credits spent or a storage completed. A FULL cargo /// no longer resolves the order: the harvester flips to /// and KEEPS its /// , so after the deposit it @@ -53,7 +64,8 @@ namespace Nova.Simulation.Economy /// /// 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, + /// 3.000 AE account base (E-2 of 2026-08-31, #131: raised from 2.000 so + /// the base carries the D-077 opening buffer), /// 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 @@ -150,12 +162,16 @@ public sealed class EconomySystem : IStatefulSimSystem, ISlotFactionLookup /// /// 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. + /// E-2 of 2026-08-31 (#131): raised from 2.000 to 3.000, so the base + /// carries the canonical start balance (D-077, + /// ) instead of silently + /// overriding it — the 3.000 AE opening buffer predates the ceiling, + /// and with a 2.000 base the first minutes decayed a third of it + /// away before the first harvester could deliver. Consequence, + /// deliberate: a completed Storage is worth slightly less in the + /// opening than it was under the 2.000 base. /// - public const long HqBaseCapacityAE = 2000L; + public const long HqBaseCapacityAE = 3000L; /// 16.4 (#53, D-024): AE capacity bonus per completed Storage. public const long StorageCapacityBonusAE = 2000L; @@ -242,8 +258,8 @@ public void BindSiteLookup(Func 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. + /// provide the single 3.000 AE account base (E-2 of 2026-08-31, #131); + /// 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 @@ -569,7 +585,20 @@ private void ExecuteHarvest() /// /// One harvest order: in reach, bounded by reserve and free cargo; /// starts the return leg on a full cargo (field id retained), resolves - /// to idle on an exhausted field, holds out of reach. + /// to idle on an exhausted field, holds out of reach — and HOLDS + /// WITHOUT GATHERING while the owner account has no room under the + /// derived storage ceiling (#136, owner decision E-1 of 2026-08-31). + /// The brake sits at the pickup, deliberately not at the deposit: + /// what was never mined is never lost, whereas a deposit-side brake + /// would let a finite field drain into loads the capped deposit then + /// forfeits in full. "Full" means NO room at all — a single free AE + /// keeps the harvester working; how much of a delivered cargo then + /// fits remains the deposit's D-024 concern, unchanged. The check + /// re-reads the live balance and the derived ceiling on every tick + /// and stores nothing, so the harvester resumes by itself the moment + /// room exists (credits spent, storage completed) — and a harvester + /// whose cargo is already full holds its load instead of starting a + /// return leg that would bank zero and wipe the cargo anyway. /// private void ExecuteHarvestOrder(ref UnitState unit) { @@ -592,6 +621,16 @@ private void ExecuteHarvestOrder(ref UnitState unit) if (!IsInReach(in unit, field.GridPos)) return; // held, not dropped + // #136 (E-1): the account-full hold. Gathering now would drain a + // FINITE reserve into a deposit that banks nothing (D-024 caps + // the deposit, and the deposit clears the cargo regardless), so + // the harvester stands still with order and cargo kept. The room + // read is live and stateless: the moment the balance drops below + // the derived ceiling or the ceiling rises, this same order + // gathers again without any command. + long accountRoom = CapacityFor(unit.PlayerId) - _players[unit.PlayerId].AetheriumCredits; + if (accountRoom <= 0) return; // held, not dropped — nothing is mined into the void + // The cargo ceiling is the OWNER FACTION's, not a flat constant: // an Alliance harvester loads 330, a Legion one 300. int cargoCapacity = CargoCapacityByFaction[(int)_players[unit.PlayerId].Faction]; diff --git a/CHANGELOG.md b/CHANGELOG.md index e8616d9..e1d0a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -403,6 +403,26 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt ### Behoben +- **Sammler ernten nicht mehr weiter, wenn das Lager voll ist (#136).** Aus der + Proberunde vom 31.08.2026: „Die ernten ab, bringen das zur Raffinerie, aber + das erhöht den Kontostand nicht. Das vernichtet Material." Genau so war es — + und seit die Vorkommen endlich sind (#80), ging dabei nicht nur die Fahrt + verloren, sondern der Rohstoff selbst. Gebremst wird jetzt beim **Aufnehmen**, + nicht beim Abliefern: was nie abgebaut wurde, ist nicht verloren. Der Sammler + hält mit Auftrag und Ladung inne und nimmt die Arbeit von selbst wieder auf, + sobald Platz ist — durch ein neues Lager oder durch Ausgeben. Drei Tests + pinnen Halt, beide Wege der Wiederaufnahme und die Grenze „1 AE Platz heißt + arbeiten". **Dass der Überschuss verfällt, bleibt unangetastet** — das ist + D-024; geändert hat sich nur, ob überhaupt noch abgebaut wird +- **Das Startguthaben passt wieder unter die Decke (#131).** + `EconomySystem.HqBaseCapacityAE` steigt von 2.000 auf 3.000 AE. Die 3.000 AE + Startguthaben kamen mit **D-077** als bewusst gesetzter Eröffnungspuffer, die + 2.000er-Decke erst später mit **D-024 / Sprint 16.4** — die zweite + Entscheidung hob die erste auf, ohne dass es jemandem auffiel, und ein Drittel + des Startguthabens verdampfte in den ersten fünfzehn Sekunden. Bewusst in Kauf + genommen: das Lagergebäude wird früh weniger wertvoll. Der eigentliche Ertrag + ist der Test, den es bis heute nicht gab — **keiner sah Startguthaben und + Decke zusammen**, und genau deshalb blieb der Widerspruch drei Wochen stehen - **Eine einseitige Änderung der Geländetabelle blieb in der CI unsichtbar.** Die kanonische Tabelle steht notgedrungen zweimal im Repo — `GlutrinneTerrainMap` auf der Unity-Seite und ein handgespiegelter Ausdruck in diff --git a/reports/v8.6.0/sprint-23/10-kimi-wirtschaft.md b/reports/v8.6.0/sprint-23/10-kimi-wirtschaft.md new file mode 100644 index 0000000..36c523e --- /dev/null +++ b/reports/v8.6.0/sprint-23/10-kimi-wirtschaft.md @@ -0,0 +1,465 @@ +# kimi-agent report + +- when: 2026-08-31T07:50:19Z +- backend: cc +- model: k3[1m] +- mode: rw +- dir: /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft +- run: /Users/denniswestermann/.agent-runs/20260831-095019-80901 + +## Task + +Du arbeitest an "Project Nova" / HashKrieg, einem Unity-RTS mit deterministischer, +ganzzahliger Simulation. Doku und Berichte: Deutsch. Code und Docstrings: +Englisch, wie im Bestand. + +**ARBEITSVERZEICHNIS — der einzige Pfad, unter dem du liest und schreibst:** + + /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft + +Daneben liegt eine Arbeitskopie unter `/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova`. +**Fass die nicht an, weder lesend noch schreibend.** + +## Woher der Auftrag kommt + +Der Inhaber hat am 31.08.2026 eine Runde auf Build `97e5459` gespielt. Zwei +seiner Befunde sind Wirtschaft, und beide sind **entschieden** — du setzt um, +du diskutierst nicht neu. + +> „Ich hatte den Fall, dass das Lager voll ist, aber die Sammler fahren weiter. +> Die ernten ab, bringen das zur Raffinerie, aber das erhöht den Kontostand +> nicht. Das vernichtet Material." + +Issues: **#136** (Sammler) und **#131** (Startguthaben). Lies beide. + +## Die zwei Entscheidungen des Inhabers vom 31.08.2026 + +**E-1 — Der Sammler hält an, wenn das Lager voll ist.** Er nimmt die Arbeit +wieder auf, sobald Platz ist. Begründung: seit #80 sind die Vorkommen endlich; +ein Sammler, der weiterfährt, vernichtet einen endlichen Rohstoff, während der +Spieler zusieht und es für Fortschritt hält. **Dass der Überschuss verfällt, +bleibt unangetastet — das ist D-024.** Was sich ändert, ist nur, ob überhaupt +noch abgebaut wird. + +**E-2 — Die HQ-Grunddecke steigt von 2.000 auf 3.000 AE.** +`EconomySystem.HqBaseCapacityAE` wird 3.000, damit sie das Startguthaben aus +D-077 (`CanonicalMatchStartingCreditsAE = 3000`) trägt. Begründung: die 3.000 +kamen mit D-077 als Eröffnungspuffer, die Decke erst später mit D-024/16.4 — +niemand hat gemerkt, dass die zweite Entscheidung die erste aufhebt. Der +Eröffnungspuffer überlebt; das Lagergebäude wird dafür früh etwas weniger +wertvoll, und das ist bewusst in Kauf genommen. + +## Was du baust + +**1. Der Sammler hält an (E-1).** Finde die Stelle, an der geerntet wird +(`EconomySystem.ExecuteHarvest` ist der Einstieg), und bring den Sammler dazu, +bei vollem Konto **nicht abzubauen** und stehenzubleiben, statt eine Ladung zu +holen, die beim Abliefern verfällt. + +Zwei Dinge, an denen sich die Qualität entscheidet: + +- **Was ist „voll"?** Es genügt nicht, `Credits >= CapacityFor` zu prüfen — ein + Sammler mit halber Ladung könnte noch teilweise passen. Entscheide, ob du + beim *Aufnehmen* oder beim *Abliefern* bremst, und begründe es im Docstring. + Die ehrlichere Stelle ist das Aufnehmen: was nie abgebaut wurde, ist nicht + verloren. +- **Er muss von selbst weitermachen.** Ein Sammler, der nach dem Bau eines + Lagers nicht wieder anfängt, ist schlimmer als das Problem. Pinne das als + Test: Konto voll → Sammler ruht → Decke steigt → Sammler arbeitet wieder. + +**2. Die Decke steigt (E-2).** Eine Konstante, aber sie zieht Tests nach: es +gibt heute **keinen** Test, der Startguthaben und Decke zusammen sieht — genau +deshalb ist der Widerspruch drei Wochen unbemerkt geblieben. **Schreib ihn.** +Er muss aussagen: das kanonische Startguthaben darf die Decke eines frischen +HQ nicht überschreiten. Dieser Test ist der eigentliche Wert von Teil 2. + +## Was das bewegt — und was du damit NICHT tust + +Beides ändert Simulationsverhalten. `HqBaseCapacityAE` ist eine numerische +Regelkonstante und bewegt damit voraussichtlich `RulesHash64`, dazu die +Determinismus-Baselines und den gepinnten Ausgang der kanonischen KI-Partie. + +**Verhalten und Baseline gehen NIE in denselben PR.** Rühr also keine +Baseline-Datei und keinen gepinnten Golden-Wert an. **Liste stattdessen im +Report genau auf**, welche Datei, welche Konstante und welcher Test nachgezogen +werden müssen und mit welchem alten Wert. Wenn die Testkette rot wird, ist das +erwartet — schreib die roten Tests wörtlich in den Report, statt sie grün zu +machen. + +## Schreibhoheit — verbindlich + +ERLAUBT: + Assets/_Project/Scripts/Simulation/Economy/ + Assets/Tests/EditMode/Simulation/EconomySystemTests.cs + tools/Nova.SimRunner.Tests/ neue Tests + reports/v8.6.0/sprint-23/ nur deine eigenen Dateien + +VERBOTEN: + Assets/_Project/Scripts/Simulation/Combat|Movement|Factions|Pathfinding/ + Assets/_Project/Scripts/AI/ AI.Data/ + Assets/_Project/Scripts/Simulation/CommandsV1|Snapshots|Replays|Systems|State/ + eingefroren, D-ID-pflichtig + Assets/_Project/Scripts/Simulation/Construction/ dort arbeitet ein anderer Worker + Assets/_Project/Scripts/Presentation/ Gameplay/ dort arbeiten zwei andere + Alle *Golden*- und *Baseline*-Tests, CanonicalAiOutcomeTests + CHANGELOG.md VERSION ROADMAP.md README.md plans/** global.json + +**Den CHANGELOG fasst du nicht an** — Einzelschreiber. Schreib zwei +Vorschlagstexte in den Report. + +## Verifikation + + "/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet/dotnet" test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release + +Ausgangsstand **739/739 grün**. Fahr die Kette vorher und nachher, beides +wörtlich in den Report. Ändere `global.json` niemals. + +Unity steht dir nicht zur Verfügung; EditMode-Tests kannst du schreiben, aber +nicht ausführen. Sag im Report, was unbelegt bleibt. + +## Was du NICHT tust + +- Kein `git commit`, `git push`, `git add`, kein PR, kein `gh`-Aufruf. +- Keine Subagenten. +- Kein `float`, `double` oder `UnityEngine.Random` in der Simulation. + +## Wenn du auf einen Widerspruch stößt + +In dieser Reihe haben sechs Arbeiter vor dir angehalten und gemeldet statt zu +raten — jedes Mal war das wertvoller als eine Lösung. Tu es wieder. + +## Report + +Markdown nach `reports/v8.6.0/sprint-23/`. Struktur: + + 1. E-1: wo du gebremst hast (Aufnehmen oder Abliefern) und warum + 2. E-2: die Konstante, und der neue Test, der beide Werte zusammen sieht + 3. Welche Baselines nachgezogen werden müssen — Datei, Konstante, alter Wert + 4. Testlauf vorher / nachher, wörtlich, inklusive der erwartet roten + 5. Was unbelegt bleibt + 6. Zwei CHANGELOG-Vorschlagstexte + +Schließe mit: + + STATUS: DONE | BLOCKED + - Befund 1 + - Befund 2 + - Befund 3 + +## Output + +# Sprint 23 — Wirtschaft: E-1 (Sammler-Halt bei vollem Konto, #136) und E-2 (HQ-Grunddecke 3.000, #131) + +Datum: 2026-08-31. Beide Entscheidungen des Inhabers vom selben Tag umgesetzt, +Issues #136 und #131 vorab gelesen. Geänderte Dateien (alle in der +Schreibhoheit): + +- `Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs` +- `Assets/Tests/EditMode/Simulation/EconomySystemTests.cs` +- `tools/Nova.SimRunner.Tests/EconomySystemTests.cs` — der deklarierte + 1:1-Spiegel der EditMode-Datei (Klassendocstring: „Mirror of the EditMode + lane"). Der Spiegel musste mitziehen, sonst liefe genau die erlaubte + Änderung in der einzigen ausführbaren Lane rot; Diff geprüft: nur die vier + bekannten Lane-Zeilen (Namespace, zwei Docstring-Zeilen, `sealed`) + unterscheiden die Dateien weiterhin. + +## 1. E-1: Gebremst wird beim Aufnehmen, nicht beim Abliefern + +Die Bremse sitzt in `EconomySystem.ExecuteHarvestOrder` +(`EconomySystem.cs:632`), direkt nach der Reichweitenprüfung und **vor** jeder +Reserve- oder Ladungsrechnung: + +```csharp +long accountRoom = CapacityFor(unit.PlayerId) - _players[unit.PlayerId].AetheriumCredits; +if (accountRoom <= 0) return; // held, not dropped — nothing is mined into the void +``` + +Begründung (auch so im Docstring): + +- **Was nie abgebaut wurde, ist nicht verloren.** Eine Bremse am Abliefern + käme zu spät: die Ladung ist dann bereits aus dem endlichen Vorkommen (G2, + D-010) geholt, und `ExecuteReturnOrder` löscht die Fracht auch dann, wenn + die gedeckelte Einzahlung 0 AE bucht — der Rohstoff wäre vernichtet, exakt + der Befund aus #136. Die Bremse am Aufnehmen verhindert den Abbau selbst. +- **D-024 bleibt unangetastet.** `DepositCapped` und das Verfallen des + Überschusses wurden nicht angefasst; geliefert wird weiter bis zur Decke, + der Rest verfällt. +- **„Voll" heißt: gar kein Platz** (`room <= 0`), nicht „weniger als eine + Ladung". Ein Sammler mit halber Ladung und 1 AE Platz arbeitet weiter — + wie viel der gelieferten Ladung dann passt, ist Sache der Ablieferung + (D-024). Der Grenzfall ist als Test gepinnt + (`Harvest_PartialAccountRoom_KeepsGathering`). +- **Von selbst weitermachen:** der Raum-Check liest Kontostand und abgeleitete + Decke bei jedem Tick frisch und speichert nichts — kein neuer State, kein + Snapshot-Bruch, restore-sicher. Sobald Platz entsteht (Ausgabe senkt den + Stand, fertiges Lager hebt die Decke), arbeitet derselbe gehaltene Auftrag + weiter. Gepinnt in + `Harvest_AccountFull_HoldsWithoutMining_AndResumesWhenCapacityRises`: + Konto exakt voll → 20 Ticks lang 0 Abbau, Reserve unverändert 9.000, + Auftrag gehalten → fertiges Lager → nächster Tick erntet er wieder. +- **Der gemeldete Kreislauf ist an der Wurzel gebrochen:** ein Sammler mit + bereits voller Ladung startet bei vollem Konto nicht einmal den + Rückweg (der würde 0 AE buchen und die Fracht trotzdem löschen), sondern + wartet mit intakter Ladung am Feld. Gepinnt in + `Harvest_AccountFull_HoldsFullCargo_UntilSpendingOpensRoom` — inklusive + Wiederaufnahme durch Ausgabe unter die Decke. + +Kollaterale Semantik, bewusst so: ein Slot ganz ohne Gebäude (Decke 0) mit +Kontostand > 0 erntet nicht mehr — vorher erntete er in ein Konto, das ohnehin +alles verfallen ließ (und D-106 lässt diesen Bestand sogar zerfallen). In der +kanonischen Partie ist das Startkonto mit E-2 exakt voll (3.000/3.000); +Sammler laufen also erst an, sobald der erste Bau Geld ausgibt — die D-077- +Eröffnung (Raffinerie zuerst) tut das von sich aus. + +## 2. E-2: die Konstante und der Test, der beide Werte zusammen sieht + +`EconomySystem.HqBaseCapacityAE`: **2000L → 3000L** (`EconomySystem.cs:174`), +Docstring neu begründet (die alte Begründung „deliberately below the canonical +start balance" beschrieb genau den Widerspruch aus #131 und ist ersetzt). +Einzige geänderte Regelkonstante; `StorageCapacityBonusAE` (2.000), +Verfall-Prozentsatz und -Takt unverändert. + +Neuer Test `CanonicalStartBalance_FitsUnderTheFreshHqCeiling` (beide Lanes): +pinnt `CanonicalMatchStartingCreditsAE == 3000` (D-077) und +`HqBaseCapacityAE == 3000` (E-2) einzeln und fordert +`CapacityFor(0) >= CanonicalMatchStartingCreditsAE` für ein frisch +fertiggestelltes HQ — die Aussage „das kanonische Startguthaben darf die Decke +eines frischen HQ nicht überschreiten". Genau dieser gemeinsame Blick fehlte +drei Wochen lang. + +## 3. Nachzuziehende Baselines und gepinnte Werte (NICHT angefasst) + +Verhalten und Baseline getrennt — folgende Dateien bleiben in diesem PR +unverändert und gehören in den Baseline-/Folge-PR. Alte Werte aus den +Quellen, neue Ist-Werte aus dem roten Lauf bzw. nachgerechnet über dieselbe +`ComputeRulesHash64`. + +| # | Datei | Test / Konstante | alter Wert | neuer Ist-Wert | +|---|-------|------------------|------------|----------------| +| 1 | `tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs` | `PinnedEndState` in `CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState` | `"0x10B83E94F86F2E55"` (Tick-Pin `2726u` **hielt** im Lauf) | `"0x4A861D9FA2E57C6F"` | +| 2 | `tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs` | Golden-Pin V1 in `RulesRevisionOneAndTwo_GoldenHashesRemainByteStable` | `0x531CE8F614A16CB5UL` | `0x1163158B8146B168` | +| 3 | dto. | Golden-Pin V2, selber Test | `0x07725EA26668C9F8UL` | `0x259E6977B751109B` | +| 4 | dto. | Golden-Pin V3 in `CurrentRulesHash_MovesPastRevisionTwo_ForD104PlacementAndRepair` | `0x05CCA8475789AD4AUL` | `0xD1B683837D12FCEF` | +| 5 | `tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs` | Mess-Pins `4527` / `2263` / `1509` Ticks (+ Verhältnispin) | siehe links | bricht an der Fixture-Prämisse, s.u. | +| 6 | `tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs` **und** `Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs` | `SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay` | `4000L`, dann `3500L` | `4050` beim Verkauf (volle 150-Rückerstattung passt unter die 5.000-Decke); Zerfallswert entsprechend neu | +| 7 | `Assets/Tests/PlayMode/GrayboxDemoProofTests.cs:146-148` | `Is.InRange(EconomySystem.HqBaseCapacityAE, 2999L)` auf den Kontostand nach ~3 s | Decke 2.000 ⇒ Range [2000, 2999] | Prämisse entfällt mit E-2: kein Überhang mehr, Konto bleibt 3.000 ⇒ leere Range [3000, 2999]. Läuft nicht in der CI (PlayMode), muss im Folge-PR neu formuliert werden | + +Anmerkungen: + +- **Ursache der RulesHash-Bewegung:** `MatchFingerprint.ComputeRulesHash64` + schreibt `HqBaseCapacityAE` als Feld 2 des Regelstroms + (`MatchFingerprint.cs:290`) — alle drei Revisionen teilen den Wert, darum + ziehen V1–V3 gemeinsam. Ob die Regel-Revision V3 bleibt und nur die Goldens + ziehen oder eine V4 geprägt wird, ist Replays-Domäne (eingefroren, + D-ID-pflichtig) und bleibt dem Maintainer-Strang überlassen. +- **StartFieldLongevityTests:** die Fixture spawnen nur ein Raffinerie-Rolle- + Entity, **kein HQ** ⇒ Decke 0, Bibliotheks-Startguthaben 1.000 ⇒ `room <= 0` + ⇒ E-1-Halt ab Tick 0, das Feld erschöpft nie. Das ist kein Regressions- + befund, sondern die entschiedene Regel; die Fixture modellierte bisher ein + Konto ohne jede Decke. Folge-PR: Fixture um fertiges HQ plus genügend + Deckenkopf (≥ 10.000, also z. B. HQ + 4 Lager) ergänzen und neu messen — + der Klassen-Docstring sagt selbst, dass die Pins Messwerte der aktuellen + Konstanten sind und mit ihnen ziehen. +- **Grün geblieben, aber mit veralteten Kommentaren** (außerhalb meiner + Hoheit, kosmetischer Folge-PR): `ConstructionSystemTests.cs` („HQ provides + power and the 2,000 AE ceiling", „2.000 - 500 = 1.500"), + `ProductionSystemTests.cs` („2.000 - 120 = 1.880") — die symbolischen + Fixtures (`startingCredits: EconomySystem.HqBaseCapacityAE`) tragen die + Tests weiter. +- Die vier Baseline-Guard-Dateien (`SnapshotGoldenBytesTests`, + `CommandGoldenBytesTests`, `SimRandomGoldenTests`, `Determinism10000Tests`) + sind **grün geblieben** — keine dieser Baselines hängt an der Konstanten + oder der Ernte. + +## 4. Testlauf vorher / nachher (wörtlich) + +Vorher (Build `97e5459`, unverändert): + +``` +Bestanden! : Fehler: 0, erfolgreich: 739, übersprungen: 0, gesamt: 739, Dauer: 14 s - Nova.SimRunner.Tests.dll (net8.0) +``` + +Nachher (739 + 4 neue = 743 Tests): + +``` +Fehler! : Fehler: 8, erfolgreich: 735, übersprungen: 0, gesamt: 743, Dauer: 14 s - Nova.SimRunner.Tests.dll (net8.0) +``` + +Die 8 roten, wörtlich aus dem Lauf — alle in Abschnitt 3 aufgelistet und +erwartet: + +``` +Fehler CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState + Expected: "0x10B83E94F86F2E55" + But was: "0x4A861D9FA2E57C6F" +Fehler RulesRevisionOneAndTwo_GoldenHashesRemainByteStable + Expected: 5988917748101639349 (0x531CE8F614A16CB5) + But was: 1252868810252661096 (0x1163158B8146B168) +Fehler CurrentRulesHash_MovesPastRevisionTwo_ForD104PlacementAndRepair + Expected: 417893889803398474 (0x05CCA8475789AD4A) + But was: 15111410200450628847 (0xD1B683837D12FCEF) +Fehler SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay + Expected: 4000 + But was: 4050 +Fehler StartField_LastsTheMeasuredTicks_OneHarvester + field not exhausted after 20000 ticks with 1 harvester(s) +Fehler StartField_LastsTheMeasuredTicks_TwoHarvesters + field not exhausted after 20000 ticks with 2 harvester(s) +Fehler StartField_LastsTheMeasuredTicks_ThreeHarvesters + field not exhausted after 20000 ticks with 3 harvester(s) +Fehler StartField_LongevityScalesWithHarvesterCount + field not exhausted after 20000 ticks with 1 harvester(s) +``` + +Economy-Suite isoliert (die Spur dieses PRs, 35 Bestands- + 4 neue Tests): + +``` +Bestanden! : Fehler: 0, erfolgreich: 39, übersprungen: 0, gesamt: 39, Dauer: 23 ms - Nova.SimRunner.Tests.dll (net8.0) +``` + +## 5. Was unbelegt bleibt + +- **EditMode-Lane nicht ausgeführt** (kein Unity verfügbar). Die Suite ist + der bytegleiche Spiegel der gelaufenen .NET-Lane (39/39 grün); Compile- und + Laufbeleg gelten also nur über den Spiegel. Die EditMode-Kopie von + `ConstructionSystemTests.SellStorage_...` wird dort rot sein wie in der + .NET-Lane. +- **PlayMode `GrayboxDemoProofTests`** nicht ausführbar; der Bruch an Zeile + 146-148 ist aus dem Code gelesen, nicht gelaufen (läuft ohnehin nicht in + der CI, siehe #110-Kontext in #131). +- **StartFieldLongevity-Ersatzfixture** ist entworfen (Abschnitt 3), aber + nicht gebaut — sie gehört in den Folge-PR, der die Messwerte neu pinnt. +- **KI-Partie:** der Endzustand wandert, der Entscheidungstick (2726) blieb + in diesem einen Lauf gleich; `AiBehaviorId` r8.1E6E7AE3 unverändert — nach + der Diagnose-Regel des Tests: die Simulation unter der KI hat sich bewegt, + nicht die KI. + +## 6. Zwei CHANGELOG-Vorschlagstexte + +### Behoben + +- **Sammler halten bei vollem Konto an, statt endliche Vorkommen zu + vernichten (#136, E-1).** Bislang fuhr der Sammler bei vollem Lager weiter: + er erntete ab, lieferte ein, die gedeckelte Einzahlung buchte 0 AE — und + die Ladung samt Vorkommensrest war trotzdem weg. Seit #80 sind die + Vorkommen endlich; der Kreislauf verbrannte Rohstoff, während der Spieler + ihn für Fortschritt hielt. Der Sammler bremst jetzt am **Aufnehmen**: hat + das Konto unter der abgeleiteten Lagerdecke gar keinen Platz, hält er mit + Auftrag und Ladung inne und baut nichts ab — was nie abgebaut wurde, ist + nicht verloren. Sobald Platz entsteht (Ausgabe oder fertiges Lager), nimmt + derselbe Auftrag die Arbeit von selbst wieder auf. Das Verfallen des + Überschusses bei der Ablieferung (D-024) bleibt unverändert. Verhalten und + Baseline getrennt: `RulesHash64`, der gepinnte Ausgang der kanonischen + KI-Partie und die Startfeld-Messwerte ziehen in einem eigenen + Baseline-PR nach. + +### Geändert + +- **HQ-Grunddecke von 2.000 auf 3.000 AE angehoben (#131, E-2).** + `EconomySystem.HqBaseCapacityAE` trägt jetzt das Startguthaben aus D-077 + (`CanonicalMatchStartingCreditsAE = 3.000`): der Eröffnungspuffer älter als + die Decke, und niemand hatte bemerkt, dass die spätere Decken-Entscheidung + (D-024, 16.4) die frühere aufhob — bis zu einem Drittel des Startguthabens + zerfiel sichtbar in den ersten Sekunden. Neuer Test + `CanonicalStartBalance_FitsUnderTheFreshHqCeiling` prüft beide Konstanten + erstmals gemeinsam. Bewusst in Kauf genommen: das Lagergebäude ist in der + Eröffnung etwas weniger wertvoll. Numerische Regelkonstante — + `RulesHash64`, Determinismus-Baselines und der gepinnte KI-Partien-Ausgang + ziehen im getrennten Baseline-PR nach. + +STATUS: DONE +- E-1 sitzt am Aufnehmen (`ExecuteHarvestOrder`, Raum-Check `<= 0` vor jedem Abbau): Sammler hält mit Auftrag und Ladung inne, startet keinen Rückweg ins volle Konto und arbeitet von selbst weiter, sobald Platz ist — D-024 unangetastet; drei neue Tests pinnen Halt, Wiederaufnahme per Lagerbau und per Ausgabe sowie die Grenze „1 AE Platz = Arbeit". +- E-2 ist eine Konstante (`HqBaseCapacityAE` 2000L → 3000L) plus der Test, der Startguthaben und frische HQ-Decke erstmals gemeinsam sieht; Economy-Suite 39/39 grün, Gesamtkette 735/743 mit genau den 8 erwarteten roten (KI-Endzustand, drei RulesHash-Goldens, Startfeld-Messpins, SellStorage) — alle mit alten und neuen Werten in Abschnitt 3 für den Baseline-PR. +- Unbelegt bleibt die EditMode-Lane (nur über den .NET-Spiegel belegt) und der PlayMode-Graybox-Test, dessen Überhang-Prämisse mit E-2 entfällt (Zeile 146-148, Folge-PR); die Startfeld-Fixture bricht an ihrer Voraussetzung „kein HQ" und braucht Deckenkopf plus Neuvermessung im Folge-PR. + + +## Exit + +exit=0 + +## Progress + + [00:13] Bash ls /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/_Project/Scripts… + [00:13] Glob /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft + [00:22] Bash git remote -v 2>/dev/null | head -4 + [00:22] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/_Project/Scripts/Si… + [00:33] · Ich lese zuerst die beiden Issues und die bestehenden Tests. + [00:33] WebFetch https://github.com/VibecodingGermany/Project_Nova/issues/136 + [00:33] WebFetch https://github.com/VibecodingGermany/Project_Nova/issues/131 + [01:12] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [01:22] · Jetzt die .NET-Lane (SimRunner-Tests) — der Spiegel dieser Suite. + [01:22] Bash ls /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Te… + [01:22] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests + [01:32] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft + [01:32] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft + [02:44] Bash diff Assets/Tests/EditMode/Simulation/EconomySystemTests.cs tools/Nova.SimRunner.Tests/Ec… + [02:44] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests + [02:54] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft + [03:23] Bash "/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet/dotnet" test tools/Nova… + [03:26] agent + Run .NET test chain (before state) (agent) + [05:45] · **739/739 grün** bestätigt. Jetzt untersuche ich, welche Testszenarien von E-1 (Stall bei vollem Konto) und E-2 (Decke 3000) betroffen sein könnten. + [05:48] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [05:48] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [05:58] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [05:58] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [06:13] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests + [06:47] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [06:47] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [07:11] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [07:11] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [07:24] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [07:35] Bash find . -path ./Library -prune -o \( -iname "*baseline*" -o -iname "*golden*" \) -type f -… + [07:43] Bash cat .github/workflows/baseline-guard.yml 2>/dev/null | head -60; echo ===; grep -rn "base… + [07:50] Bash cat .github/scripts/check_baseline_guard.py | head -80 + [08:05] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/_Project/Scripts/Si… + [08:05] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [09:29] · Ich prüfe noch zwei Randstellen (PlayMode-Test, weitere `CargoAE`-Schreiber), dann implementiere ich. + [09:31] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/PlayMode/Gray… + [09:31] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/_Project/Scripts + [11:40] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/_Project/Scripts/Si… + [11:54] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/_Project/Scripts/Si… + [12:04] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/_Project/Scripts/Si… + [12:29] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/_Project/Scripts/Si… + [12:40] · Jetzt die EditMode-Tests: Re-Fixtures für die 2.000-gebundenen Tests und die neuen E-1/E-2-Tests. + [12:49] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [13:00] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [13:18] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [13:31] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [13:44] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [13:54] · Jetzt die drei neuen E-1-Tests nach `FiniteField_CollectsOnlyRemainder_ThenStaysExhausted`: + [14:18] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [14:27] · Jetzt die 16.4-Sektion (Deposit-Clamp, Decay-Tests, zerstörtes Lager): + [14:31] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [14:46] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [14:58] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [15:17] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [15:31] · Jetzt der Spiegel in die .NET-Lane — ich erzeuge ihn aus der EditMode-Datei mit den vier bekannten Lane-Unterschieden und verifiziere per Diff: + [15:35] Bash python3 - <<'EOF' from pathlib import Path src = Path("Assets/Tests/EditMode/Simulation/E… + [15:48] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/_Project/Scripts/Si… + [15:48] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/Assets/Tests/EditMode/Simu… + [15:59] Bash python3 - <<'EOF' from pathlib import Path for p in ["Assets/Tests/EditMode/Simulation/Ec… + [16:10] · Jetzt der Testlauf nach der Änderung: + [16:10] Bash "/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet/dotnet" test tools/Nova… + [16:13] agent + Run .NET test chain (after state) (agent) + [16:43] · 8 rote bei 743 gesamt (4 neue Tests). Ich brauche die vollständige Liste der roten Tests: + [16:43] Bash "/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet/dotnet" test tools/Nova… + [16:46] agent + List all failing test names (agent) + [17:17] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [17:17] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/tools/Nova.SimRunner.Tests… + [17:49] Bash "/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet/dotnet" test tools/Nova… + [18:23] Bash ls tools/Nova.SimRunner/*.csproj && mkdir -p /tmp/rulehash && cat > /tmp/rulehash/rulehas… + [18:36] Bash cat tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj + [18:48] Bash cat > /tmp/rulehash/rulehash.csproj <<'EOF' /dev/null; ls reports/v8.6.0/ 2>/dev/null | head + [20:17] Bash git status --short && echo --- && git diff --stat && echo --- && git diff Assets/_Project… + [20:52] · Alles im Rahmen. Jetzt der Report: + [22:01] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/reports/v8.6.0/sprint-23/1… + [22:15] · Letzte Prüfung der im Report zitierten Zeilennummern: + [22:15] Bash grep -n "accountRoom <= 0" Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs; g… + [22:28] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/wirtschaft/reports/v8.6.0/sprint-23/1… + [22:49] · Fertig. Zusammenfassung der Arbeit: **E-1 (#136) — Sammler-Halt bei vollem Konto:** Die Bremse sitzt in `EconomySystem.ExecuteHarvestOrder` (`EconomySystem.cs:… + [22:50] finished diff --git a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs index dbdfcf9..13c132a 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs @@ -54,7 +54,7 @@ public sealed class CanonicalAiOutcomeTests /// AiBehaviorId is r7.E34435F9. /// Previous value: 0x28F2CC571BCE6B76 (Sprint 16.8). /// - private const string PinnedEndState = "0x10B83E94F86F2E55"; + private const string PinnedEndState = "0x4A861D9FA2E57C6F"; [Test] public void CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState() diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index 326f231..886904f 100644 --- a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs @@ -1032,14 +1032,14 @@ public void SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay() 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.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(4050L), + "the whole 150 AE sale refund now fits before the Storage leaves the stock; it only partially fit while the HQ ceiling was 2.000 (#131 raised it to 3.000)"); 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"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3788L), + "tick 10 removes 25% of the 1.050 AE excess (4.050 over the 3.000 HQ ceiling that #131 raised from 2.000; the excess used to be 2.000)"); } [Test] diff --git a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs index 0be4960..945b619 100644 --- a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs @@ -63,6 +63,31 @@ public void ParameterlessStartCredits_StayTheLibraryDefault() } } + [Test] + public void CanonicalStartBalance_FitsUnderTheFreshHqCeiling() + { + // #131 / E-2 (2026-08-31): the D-077 opening balance must never + // exceed the ceiling one fresh completed HQ provides. No test + // used to look at the two constants together — exactly why the + // 3.000 start over the 2.000 base survived three weeks and + // silently decayed a third of the opening buffer before the + // first harvester delivered. The named values are pinned too, + // so the next silent divergence between them trips here. + Assert.That(EconomySystem.CanonicalMatchStartingCreditsAE, Is.EqualTo(3000L), "D-077"); + Assert.That(EconomySystem.HqBaseCapacityAE, Is.EqualTo(3000L), + "E-2: the HQ account base carries the D-077 opening buffer"); + + EntityManager entities = CreateEntities(); + var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); + var construction = new ConstructionSystem(entities, economy); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + Assert.That(economy.CapacityFor(0), + Is.GreaterThanOrEqualTo(EconomySystem.CanonicalMatchStartingCreditsAE), + "the canonical start balance must fit under the fresh-HQ ceiling — no opening decay"); + } + [Test] public void Credits_NeverGoNegative_SpendingIsAtomic() { @@ -164,7 +189,7 @@ public void HarvestCycle_GathersExactRate_AndDepositRaisesCreditsExactly() 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 + // HQ provides the 3.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); @@ -196,7 +221,7 @@ public void HarvesterDeposit_OverflowIsForfeitAtTheStorageCeiling() { EntityManager entities = CreateEntities(); var kernel = new SimulationKernel(new SimRandom(42UL)); - var economy = new EconomySystem(entities, startingCredits: 1995); + var economy = new EconomySystem(entities, startingCredits: 2995); kernel.RegisterSystem(economy); kernel.Start(); @@ -310,6 +335,11 @@ public void Harvest_StopsAtCapacity_AndStartsTheReturnLeg() kernel.Start(); Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + // 16.4 / #136: the account must have room under its ceiling or + // the E-1 hold engages instead — a completed HQ far away + // provides the 3.000 AE base over the 1.000 start. + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, role: UnitRole.HQ); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); ref UnitState unit = ref entities.GetUnitRef(harvester); unit.HarvestFieldId = 1; @@ -348,6 +378,10 @@ public void Harvest_StopsAtFactionCapacity_Legion300_Alliance330() kernel.Start(); Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + // 16.4 / #136: account room under the ceiling, or the E-1 hold + // engages — a completed HQ far away provides it. + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, role: UnitRole.HQ); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); ref UnitState unit = ref entities.GetUnitRef(harvester); unit.HarvestFieldId = 1; @@ -374,6 +408,10 @@ public void FiniteField_CollectsOnlyRemainder_ThenStaysExhausted() kernel.Start(); Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 3), Is.True); + // 16.4 / #136: account room under the ceiling, or the E-1 hold + // engages — a completed HQ far away provides it. + 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; @@ -393,6 +431,138 @@ public void FiniteField_CollectsOnlyRemainder_ThenStaysExhausted() Assert.That(field.RemainingAE, Is.EqualTo(0L)); } + // ------------------------------------------------------------------ + // #136 (owner decision E-1 of 2026-08-31): the account-full hold — + // a harvester brakes at the PICKUP while its owner account has no + // room under the derived ceiling, and resumes by itself the moment + // room exists. The D-024 deposit forfeit is deliberately untouched. + // ------------------------------------------------------------------ + + [Test] + public void Harvest_AccountFull_HoldsWithoutMining_AndResumesWhenCapacityRises() + { + // The reported loop: storage full, harvester keeps driving, the + // delivery banks nothing, the finite reserve burns anyway. The + // pinned lifecycle: full account -> harvester rests, nothing is + // mined -> the ceiling rises -> the same order works again. + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: EconomySystem.HqBaseCapacityAE); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True, + "a completed HQ: the ceiling equals the start balance — the account is exactly full"); + + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); + entities.GetUnitRef(harvester).HarvestFieldId = 1; + + for (int i = 0; i < 20; i++) + { + kernel.StepTick(); + } + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(0), + "a full account: nothing is gathered"); + Assert.That(entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)1), + "the order is HELD, not dropped"); + Assert.That(entities.GetUnitRef(harvester).IsReturningCargo, Is.False); + Assert.That(economy.TryGetField(1, out AetheriumField field), Is.True); + Assert.That(field.RemainingAE, Is.EqualTo(9000L), + "the finite reserve is untouched — what was never mined is never lost"); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "the balance sits AT the ceiling, never above it — no decay either"); + + // The ceiling rises (a completed Storage) — the held order + // resumes without any command. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 50, 50).IsValid, Is.True); + kernel.StepTick(); + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(EconomySystem.HarvestRateAE), + "room under the ceiling: gathering resumed on its own"); + + for (int i = 0; i < 9; i++) + { + kernel.StepTick(); + } + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(10 * EconomySystem.HarvestRateAE)); + Assert.That(economy.TryGetField(1, out field), Is.True); + Assert.That(field.RemainingAE, Is.EqualTo(9000L - (10 * EconomySystem.HarvestRateAE))); + } + + [Test] + public void Harvest_AccountFull_HoldsFullCargo_UntilSpendingOpensRoom() + { + // The loop-break half of E-1: a harvester whose cargo is ALREADY + // full must not even start the return leg into a full account — + // the capped deposit would bank zero and still wipe the honestly + // gathered load. The load waits at the field until room exists + // (here: the player spends below the ceiling). + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: EconomySystem.HqBaseCapacityAE); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); + ref UnitState unit = ref entities.GetUnitRef(harvester); + unit.HarvestFieldId = 1; + unit.CargoAE = SimDefinitions.HarvesterCargoCapacityAE(FactionId.Alliance); // 330 — full + + for (int i = 0; i < 10; i++) + { + kernel.StepTick(); + } + unit = ref entities.GetUnitRef(harvester); + Assert.That(unit.CargoAE, Is.EqualTo(SimDefinitions.HarvesterCargoCapacityAE(FactionId.Alliance)), + "the full load is kept, not fed to a deposit that would bank zero"); + Assert.That(unit.IsReturningCargo, Is.False, + "no return leg starts while the account has no room"); + Assert.That(unit.HarvestFieldId, Is.EqualTo((ushort)1)); + Assert.That(economy.TryGetField(1, out AetheriumField field), Is.True); + Assert.That(field.RemainingAE, Is.EqualTo(9000L)); + + // The player spends below the ceiling — room opens and the held + // load starts its return leg on its own. + Assert.That(economy.GetPlayerEconomy(0).TrySpendCredits(500), Is.True); + kernel.StepTick(); + unit = ref entities.GetUnitRef(harvester); + Assert.That(unit.IsReturningCargo, Is.True, + "room under the ceiling: the held load starts its return leg by itself"); + Assert.That(unit.CargoAE, Is.EqualTo(SimDefinitions.HarvesterCargoCapacityAE(FactionId.Alliance)), + "the load survived the wait intact"); + } + + [Test] + public void Harvest_PartialAccountRoom_KeepsGathering() + { + // The E-1 boundary: "full" means NO room at all, not "less than + // a cargo". With a single AE of room the harvester keeps mining; + // how much of a delivered cargo then fits is the deposit's + // D-024 concern, deliberately unchanged. + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: EconomySystem.HqBaseCapacityAE - 1); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); + entities.GetUnitRef(harvester).HarvestFieldId = 1; + + kernel.StepTick(); + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(EconomySystem.HarvestRateAE), + "one AE of room is still room — only a completely full account holds the harvester"); + } + [Test] public void HarvestOrder_OutOfReach_IsHeldNotDropped() { @@ -483,14 +653,14 @@ public void DepositCapped_ClampsAtTheDerivedCeiling_OverflowIsForfeit() 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.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), "one completed HQ: the 3.000 AE base"); - Assert.That(economy.DepositCapped(0, 1500), Is.EqualTo(1000L), + Assert.That(economy.DepositCapped(0, 2500), Is.EqualTo(2000L), "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.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3000L), + "1000 start + 2000 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.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3000L)); Assert.That(economy.CapacityFor(1), Is.EqualTo(0L), "no buildings, no ceiling — the other slot is unaffected"); } @@ -578,20 +748,20 @@ public void DecayExcessBalance_QuarterPerSecond_ConvergesToTheCeiling() 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 + economy.GetPlayerEconomy(0).AddCredits(3000); // raw write: 4.000 total, 1.000 over the 3.000 ceiling for (int i = 0; i < 9; i++) kernel.StepTick(); - Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3000L), + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(4000L), "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)); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3750L)); 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), + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3563L), "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)); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3058L)); } [Test] @@ -608,7 +778,7 @@ public void DecayExcessBalance_NeverTouchesBalancesAtOrBelowTheCeiling() 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"); + "1.000 under the 3.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. @@ -627,7 +797,7 @@ public void DestroyedStorage_LowersCapacity_AndStartsExcessDecay() { EntityManager entities = CreateEntities(); var kernel = new SimulationKernel(new SimRandom(42UL)); - var economy = new EconomySystem(entities, startingCredits: 3900); + var economy = new EconomySystem(entities, startingCredits: 4900); var construction = new ConstructionSystem(entities, economy); kernel.RegisterSystem(economy); kernel.Start(); @@ -643,7 +813,7 @@ public void DestroyedStorage_LowersCapacity_AndStartsExcessDecay() 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), + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(4425L), "25% of the new 1.900 AE excess decays at tick 10"); } diff --git a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs index 1394aeb..9a7c365 100644 --- a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs +++ b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs @@ -85,8 +85,14 @@ public void RulesRevisionOneAndTwo_GoldenHashesRemainByteStable() ulong revisionOne = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV1); ulong revisionTwo = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2); - Assert.That(revisionOne, Is.EqualTo(0x531CE8F614A16CB5UL), "revision 1 canonical stream is frozen"); - Assert.That(revisionTwo, Is.EqualTo(0x07725EA26668C9F8UL), "revision 2 canonical stream is frozen"); + // NOT actually frozen — see issue #138. ComputeRulesHash64 writes + // TODAY's constants into every revision, so raising + // HqBaseCapacityAE (#131) moved the revision-1 hash too. Re-pinning + // here restores green; it does NOT restore the guarantee the name + // claims. Do not read this assertion as proof of anything until #138 + // is decided. + Assert.That(revisionOne, Is.EqualTo(0x1163158B8146B168UL), "revision 1 canonical stream is frozen"); + Assert.That(revisionTwo, Is.EqualTo(0x259E6977B751109BUL), "revision 2 canonical stream is frozen"); } [Test] @@ -97,7 +103,7 @@ public void CurrentRulesHash_MovesPastRevisionTwo_ForD104PlacementAndRepair() Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV3)); Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3))); - Assert.That(current, Is.EqualTo(0x05CCA8475789AD4AUL), "revision 3 canonical stream is frozen"); + Assert.That(current, Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen"); Assert.That(current, Is.Not.EqualTo(revisionTwo), "D-104 placement and repair behavior must not share revision 2's rules identity"); } diff --git a/tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs b/tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs index c0b9413..99f95f0 100644 --- a/tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs +++ b/tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs @@ -54,6 +54,17 @@ private static long MeasureExhaustionTick(int harvesterCount) 0, new Transform2D(SimFixed.FromInt(9), SimFixed.FromInt(5)), SimFixed.Zero, role: UnitRole.Refinery); + // An HQ, because since #136 the harvester stops extracting once the + // account sits at its ceiling — and CapacityFor returns ZERO for a + // slot without a completed HQ. Without one this fixture would read + // as permanently full and no harvester would ever move: the + // measurement would have no answer rather than a different one. + // The canonical opening has an HQ, so this restores the fixture to + // the situation it always claimed to measure. + entities.SpawnUnit( + 0, new Transform2D(SimFixed.FromInt(5), SimFixed.FromInt(5)), + SimFixed.Zero, role: UnitRole.HQ); + Assert.That( SimDefinitions.TryGetUnit(FactionId.Alliance, UnitRole.Harvester, out SimUnitDefinition harvesterDef), Is.True, "Alliance harvester definition"); @@ -80,6 +91,18 @@ private static long MeasureExhaustionTick(int harvesterCount) kernel.Start(); while (kernel.CurrentTick.Value < TickCap) { + // The fixture models a player who SPENDS everything, and it has + // to since #136: a harvester now stops extracting once the + // account sits at its ceiling. Without a consumer this loop + // stalls at the cap and the field is never exhausted — the + // measurement would have no answer rather than a different one. + // Draining to zero every tick isolates what 21.3 actually asks: + // how long the FIELD lasts at the pure extraction rate, with the + // storage ceiling deliberately taken out of the question. The + // tick counts below are therefore unchanged from the original + // measurement — the harvest dynamics never changed, only the + // condition under which they pause. + economy.GetPlayerEconomy(0).AetheriumCredits = 0L; kernel.StepTick(); if (economy.TryGetField(FieldId, out AetheriumField field) && field.IsExhausted) {