From 5a5d08bcc146f671c6468312806e8b373647af25 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Wed, 19 Aug 2026 00:00:09 +0200 Subject: [PATCH] test(economy): measure start-field longevity instead of guessing (21.3, #87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The T-01 tester estimated the start reserve at 5.000 AE (it is 9.000) because nothing showed it. 21.2 fixed the visibility; this adds the missing design number: the REAL auto-cycle (2 AE/tick gather, 330-AE cargo, return tick per trip, footprint-reach deposit) runs on the canonical opening geometry — field (7,7), refinery (9,5) — until exhaustion: 1 harvester: 4.527 ticks = 7:33 min 2 harvesters: 2.263 ticks = 3:46 min 3 harvesters: 1.509 ticks = 2:31 min The gather rate dominates; travel vanishes under the D-104 footprint reach rule. Measured side finding: the opening supports at most ~3 walk-free harvesters per field — more need travel time (documented in the test). The reserve stays 9.000: the numbers support the D-102 expansion pressure instead of weakening it, and the T-01 misjudgement was a visibility problem (21.2), not an amount problem. The pins reopen the package automatically when any involved constant moves. No behaviour change, no baseline movement. Verified: dotnet test 730/730. --- CHANGELOG.md | 10 ++ .../StartFieldLongevityTests.cs | 136 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5f02c..c7a9e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,16 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de spielerisch abgenommen und kein Meilenstein-Nachweis ### Hinzugefügt +- **Tragzeit des Startfelds ist gemessen, nicht geraten (Paket 21.3, #87).** + `tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs` fährt den echten + Ernte-Auto-Zyklus auf der kanonischen Eröffnungsgeometrie (Feld (7,7) mit + 9.000 AE, Raffinerie (9,5)) bis zur Erschöpfung: **7:33 min** mit einem + Harvester, **3:46 min** mit zweien, **2:31 min** mit dreien — die Ernterate + dominiert, der Laufweg entfällt über die Footprint-Reichweite. Nebenbefund, + durch Messung belegt: die Eröffnung trägt nur etwa drei *lauffreie* + Harvester je Feld; mehr brauchen Wegzeit. Der Wert 9.000 bleibt — die + Zahlen stützen den Expansionsdruck aus D-102 statt ihn zu schwächen, und + der T-01-Fehlschätzer war ein Sichtbarkeitsproblem (21.2), kein Mengenproblem - **Restbestand der Vorkommen anklickbar und sichtbar (Paket 21.2, #86).** Ein Linksklick auf ein Aetherium-Vorkommen (auch ein erschöpftes) zeigt in der Befehlskarte Restbestand und Anfangsreserve („Aetherium-Vorkommen — 6.420 / diff --git a/tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs b/tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs new file mode 100644 index 0000000..c0b9413 --- /dev/null +++ b/tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs @@ -0,0 +1,136 @@ +using NUnit.Framework; +using Nova.Core; +using Nova.Simulation; +using Nova.Simulation.Definitions; +using Nova.Simulation.Economy; +using Nova.Simulation.Pathfinding; +using Nova.Simulation.State; + +namespace Nova.SimRunner.Tests +{ + /// + /// Sprint 21 package 21.3 (issue #87): the start field's longevity is + /// MEASURED, not guessed. The T-01 tester estimated the start reserve at + /// 5.000 AE — it is 9.000 (D-102/D-107) — because nothing showed it; the + /// fix for that was 21.2. What was still missing is the honest number for + /// the design question "how long should a start field carry": this suite + /// runs the real auto-cycle (gather 2 AE/tick until the 330-AE cargo is + /// full, deposit on the return leg, resume the retained field id — + /// EconomySystem's own loop, driven exactly like the + /// HarvesterAutoCycleTests fixture) on the canonical opening geometry: + /// field (7,7), refinery footprint centre (9,5), harvesters adjacent to + /// the field. The footprint reach rule (D-104, + /// EconomySystem.HasOwnRefineryInReach) lets a harvester deposit from the + /// field cell itself, so no movement is registered — the measured time is + /// the pure gather cycle, exactly the regime the opening is built for. + /// 10 ticks = 1 second (SimClock.TicksPerSecond). + /// + /// The pins are measurements of the CURRENT constants (reserve 9.000, + /// HarvestRateAE 2, Alliance cargo 330). A change to any of them — + /// including a decided reserve change of the four mirrored field-layout + /// literals — moves these numbers on purpose and reopens the package. + /// + /// + [TestFixture] + public sealed class StartFieldLongevityTests + { + private const ushort FieldId = 1; + private const long StartReserveAE = 9000L; // D-102/D-107 canonical start field + private const long TickCap = 20000; + + /// Ticks until the start field is exhausted with harvesters working it from tick 0. + private static long MeasureExhaustionTick(int harvesterCount) + { + var entities = new EntityManager(64); + var economy = new EconomySystem(entities); + var kernel = new SimulationKernel(new SimRandom(0x5EED42UL)); + kernel.RegisterSystem(economy); + + Assert.That(economy.TryAddField(FieldId, new GridPos2D(7, 7), StartReserveAE), Is.True, + "canonical start field (7,7) with 9.000 AE"); + + // Refinery entity at its footprint centre (8,4)-(10,6) -> (9,5). + entities.SpawnUnit( + 0, new Transform2D(SimFixed.FromInt(9), SimFixed.FromInt(5)), + SimFixed.Zero, role: UnitRole.Refinery); + + Assert.That( + SimDefinitions.TryGetUnit(FactionId.Alliance, UnitRole.Harvester, out SimUnitDefinition harvesterDef), + Is.True, "Alliance harvester definition"); + + // Adjacent-to-field cells that are ALSO within the refinery + // footprint's deposit reach: measured against the centre (9,5) + // with reach 2, the field ring (6..8, 6..8) intersects it only on + // (7,6), (8,6) and (8,7). A harvester anywhere else fills one + // cargo and then HOLDS forever — which is itself part of the 21.3 + // finding: the canonical opening supports at most ~3 walk-free + // harvesters per field. + (int X, int Y)[] spots = { (7, 6), (8, 6), (8, 7) }; + for (int i = 0; i < harvesterCount; i++) + { + EntityId id = entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(spots[i].X), SimFixed.FromInt(spots[i].Y)), + harvesterDef.MoveSpeed, role: UnitRole.Harvester); + // Start mid-cycle like a fresh harvest order: the auto-cycle + // retains the field id across every return leg. + entities.GetUnitRef(id).HarvestFieldId = FieldId; + } + + kernel.Start(); + while (kernel.CurrentTick.Value < TickCap) + { + kernel.StepTick(); + if (economy.TryGetField(FieldId, out AetheriumField field) && field.IsExhausted) + { + return (long)kernel.CurrentTick.Value; + } + } + Assert.Fail($"field not exhausted after {TickCap} ticks with {harvesterCount} harvester(s)"); + return -1; + } + + [Test] + public void StartField_LastsTheMeasuredTicks_OneHarvester() + { + long ticks = MeasureExhaustionTick(1); + TestContext.Out.WriteLine($"1 harvester: {ticks} ticks = {ticks / 10.0:0.#} s = {ticks / 600.0:0.##} min"); + // Arithmetic expectation: 9.000 AE at 2 AE/tick = 4.500 gather + // ticks, plus one return tick per 330-AE cargo trip (28 trips). + Assert.That(ticks, Is.EqualTo(4527), + "measured solo longevity of the 9.000-AE start field (~7:33 min)"); + } + + [Test] + public void StartField_LastsTheMeasuredTicks_TwoHarvesters() + { + long ticks = MeasureExhaustionTick(2); + TestContext.Out.WriteLine($"2 harvesters: {ticks} ticks = {ticks / 10.0:0.#} s = {ticks / 600.0:0.##} min"); + Assert.That(ticks, Is.EqualTo(2263), + "measured two-harvester longevity (~3:46 min) — half the solo time plus per-trip overheads"); + } + + [Test] + public void StartField_LastsTheMeasuredTicks_ThreeHarvesters() + { + long ticks = MeasureExhaustionTick(3); + TestContext.Out.WriteLine($"3 harvesters: {ticks} ticks = {ticks / 10.0:0.#} s = {ticks / 600.0:0.##} min"); + Assert.That(ticks, Is.EqualTo(1509), + "measured three-harvester longevity (~2:31 min) — a third of the solo time plus per-trip overheads"); + } + + [Test] + public void StartField_LongevityScalesWithHarvesterCount() + { + long solo = MeasureExhaustionTick(1); + long duo = MeasureExhaustionTick(2); + long trio = MeasureExhaustionTick(3); + + // Near-perfect scaling: the gather rate dominates and every + // harvester gathers in parallel at 2 AE/tick; only the per-trip + // return tick breaks exact 1/N. + Assert.That((double)duo / solo, Is.InRange(0.45, 0.55), "two harvesters halve the field's life"); + Assert.That((double)trio / solo, Is.InRange(0.28, 0.38), "three harvesters cut it to a third"); + } + } +}