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)
{