From dafc7dbbe90befebf7a449eee6c8e34331a476a8 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 17:49:04 +0200 Subject: [PATCH 1/2] feat(economy): let produced harvesters start with a field order Only the founding harvester harvested on its own: ConstructionSystem grants it a field when the refinery completes, while ProductionSystem spawned every later harvester with nothing but the rally target. The game teaches that harvesters work by themselves and then silently stops doing so from the second one on; an idle harvester also never got an escort. A harvester produced at a refinery is now born with a harvest order on the nearest field with remaining reserve, measured from the refinery's footprint centre via EconomySystem.TryFindNearestField, the same rule as the founding harvester. Stop and H stay with the player. The skirmish AI additionally re-tasks harvesters whose order names a foreign field, which closes a latent hang; AiBehaviorId rises to r10. Fixes #134. Decision D-121 (draft; binding with the merge). The canonical AI outcome pin moves in this PR as D-101 prescribes (decided tick 2726 unchanged); none of the four guard baselines is touched and RulesRevision stays V3 (owner to decide on a bump in the V4 window). Sim tests 786/786; Unity EditMode mirrors not run; not played. Co-Authored-By: Claude Fable 5.1 --- Assets/Tests/EditMode/AI/SkirmishAiTests.cs | 61 +++++++ .../Simulation/EconomyIntegrationTests.cs | 147 ++++++++++++++- .../Simulation/ProductionSystemTests.cs | 162 ++++++++++++++++- .../_Project/Scripts/AI.Data/AiBehaviorId.cs | 17 +- .../_Project/Scripts/AI/SkirmishAiSystem.cs | 49 +++-- .../Simulation/Production/ProductionSystem.cs | 30 +++- CHANGELOG.md | 16 ++ docs/production/DecisionLog.md | 113 ++++++++++++ .../02-issue-134-harvester-auto-harvest.md | 156 ++++++++++++++++ .../02b-review-1-issue-134.md | 168 ++++++++++++++++++ .../CanonicalAiOutcomeTests.cs | 20 ++- .../EconomyIntegrationTests.cs | 147 ++++++++++++++- .../ProductionSystemTests.cs | 162 ++++++++++++++++- tools/Nova.SimRunner.Tests/SkirmishAiTests.cs | 68 ++++++- .../Determinism10000Scenario.cs | 58 ++++-- 15 files changed, 1329 insertions(+), 45 deletions(-) create mode 100644 reports/v8.6.0/issue-lauf-2026-10-17/02-issue-134-harvester-auto-harvest.md create mode 100644 reports/v8.6.0/issue-lauf-2026-10-17/02b-review-1-issue-134.md diff --git a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs index e64e92f0..18e6f788 100644 --- a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs +++ b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs @@ -250,6 +250,17 @@ private static bool AnyHarvesterWorking(AiHost host, byte slot) return false; } + private static EntityId FirstHarvester(AiHost host, byte slot) + { + UnitState[] units = host.Entities.RawUnits; + for (int i = 0; i < host.Entities.Capacity; i++) + { + ref readonly UnitState u = ref units[i]; + if (u.IsActive && u.PlayerId == slot && u.Role == UnitRole.Harvester) return u.Id; + } + return EntityId.Invalid; + } + private static int MinCombatCellX(AiHost host, byte slot) { int min = int.MaxValue; @@ -356,6 +367,56 @@ public void SkirmishAi_QueuesHarvesters_AndHarvestIncomeRefillsTheTreasury() "idle harvesters must receive explicit Harvest intents on the own field"); } + /// + /// Regression for the hang behaviour revision 10 closes (#134, D-121). + /// Production and the founding grant pick a harvester's field as the + /// nearest one to the Refinery; this AI picks the nearest one to its + /// HQ. Where the two disagree, the r9 gather escort walked the + /// harvester to the AI's field while its order named the other one — + /// and the economy HOLDS an out-of-reach order instead of dropping it, + /// so the harvester stood there with frozen cargo for good. The + /// two-field map of this suite makes both rules agree, so the test + /// creates the disagreement directly: a third field away from the AI + /// base, and the AI's first harvester ordered onto it the tick it + /// appears. Artificial on purpose; the mechanism is the real one. + /// + [Test] + public void SkirmishAi_RetasksAHarvesterOrderedOntoAForeignField_AndItMines() + { + AiHost host = BuildMatch(Seed); + + EntityId harvester = EntityId.Invalid; + for (int i = 0; i < 2000 && !harvester.IsValid; i++) + { + host.Step(); + harvester = FirstHarvester(host, AiSlot); + } + Assert.That(harvester.IsValid, Is.True, "the AI's Refinery hands out its first harvester"); + + const ushort ownFieldId = 2; // slot 1's field at (117,117), see ApplyOpeningPosition + const ushort foreignFieldId = 3; // far from the AI's HQ and Refinery alike + Assert.That(host.Economy.TryAddField(foreignFieldId, new GridPos2D(95, 100), FieldReserveAE), Is.True); + ref UnitState forced = ref host.Entities.GetUnitRef(harvester); + forced.HarvestFieldId = foreignFieldId; + forced.IsReturningCargo = false; + Assert.That(forced.CargoAE, Is.EqualTo(0), "a fresh harvester carries nothing yet"); + + bool retasked = false; + bool mined = false; + for (int i = 0; i < 1000 && !mined; i++) + { + host.Step(); + ref readonly UnitState h = ref host.Entities.GetUnitRef(harvester); + if (h.HarvestFieldId == ownFieldId) retasked = true; + if (h.CargoAE > 0 || h.IsReturningCargo) mined = true; + } + + Assert.That(retasked, Is.True, + "the AI re-tasks a harvester whose order names a field other than its own (r10)"); + Assert.That(mined, Is.True, + "the re-tasked harvester mines instead of standing in reach of the wrong field with frozen cargo"); + } + // ---------------------------------------------------------------- // (c) Army: infantry production up to the threshold, then the march // ---------------------------------------------------------------- diff --git a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs index 4ae1aa13..bdbfa8d6 100644 --- a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs @@ -7,6 +7,7 @@ using Nova.Simulation.Economy; using Nova.Simulation.Movement; using Nova.Simulation.Pathfinding; +using Nova.Simulation.Production; using Nova.Simulation.Replays; using Nova.Simulation.State; using Nova.Simulation.Vision; @@ -19,7 +20,8 @@ namespace Nova.Simulation.Tests /// (economy registered BEFORE movement — SimulationCore.md section 2, /// phases 2/3 before 6), two-kernel determinism, hash sensitivity, /// snapshot continuation and replay playback with economy orders, plus - /// the low-power transition through a combat kill. + /// the low-power transition through a combat kill, and the produced + /// Harvester born harvesting (#134, D-121). /// Mirror of the .NET lane EconomyIntegrationTests. /// [TestFixture] @@ -83,6 +85,43 @@ public static EcoHost Create(ulong seed, int capacity = 256, ushort width = 64, return new EcoHost(kernel, entities, economy, construction, session, ingress); } + /// + /// The same canonical host with construction and production wired + /// in (#134): economy, then construction and production (phases + /// 2/3 before 4/5 before 6), and a command view that resolves + /// QueueUnit — the wiring of + /// ProductionConstructionIntegrationTests. + /// + public static EcoHost CreateWithProduction(ulong seed, int capacity = 256, ushort width = 64, ushort height = 64) + { + var entities = new EntityManager(capacity); + var pathfinding = new PathfindingSystem(width, height); + var movement = new MovementSystem(entities, pathfinding); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); + var production = new ProductionSystem(entities, economy, construction); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, width, height); + var combat = new CombatSystem(entities, fogOfWar, economy, construction); + + var kernel = new SimulationKernel(new SimRandom(seed)); + kernel.RegisterSystem(economy); + kernel.RegisterSystem(construction); + kernel.RegisterSystem(production); + kernel.RegisterSystem(pathfinding); + kernel.RegisterSystem(movement); + kernel.RegisterSystem(fogOfWar); + kernel.RegisterSystem(combat); + + var session = new MatchSession(localSlot: 0, activeSlots: new byte[] { 0, 1 }, inputDelayTicks: 1); + var ingress = new CommandIngress(session); + _ = new LocalLoopbackTransport(ingress); + kernel.BindCommands( + new UnitCommandStateView(entities, pathfinding, economy, construction, production), ingress); + + kernel.Start(); + return new EcoHost(kernel, entities, economy, construction, session, ingress); + } + /// One host lockstep iteration: seal the due batch, submit it, step, advance the session. public CommandBatch StepTick() { @@ -541,5 +580,111 @@ public void Replay_WithHarvestRejections_PlaybackReproducesResultsAndEndHash() Assert.That(playback.Kernel.CalculateStateHash(), Is.EqualTo(endHash), "playback re-executes the rejections to the identical results and end state hash"); } + + // ------------------------------------------------------------------ + // #134 (D-121): a produced Harvester is born harvesting + // ------------------------------------------------------------------ + + /// + /// Completed HQ (power and storage) at (40,40), completed Alliance + /// Refinery at origin (20,20) — footprint (20,20)-(22,22), centre + /// (21,21), default rally (23,21), in deposit reach of the footprint — + /// and one field at (24,22), in harvest reach of that rally cell. Then + /// one Harvester is queued through the sealed command path. Returns + /// the Refinery's raw id. + /// + private static uint SetUpRefineryAndQueueOneHarvester(EcoHost host) + { + Assert.That(host.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ"); + EntityId refinery = host.Construction.PlaceCompletedBuilding(0, 4, 20, 20); + Assert.That(refinery.IsValid, Is.True, "Refinery"); + Assert.That(host.Economy.TryAddField(1, new GridPos2D(24, 22), 9000), Is.True); + uint refineryRaw = UnitCommandStateView.ToRawEntityId(refinery); + Assert.That( + host.Ingress.TrySubmitIntent(CommandIntent.Create(new QueueUnitPayload(refineryRaw, 2, 1)), out _), + Is.EqualTo(CommandIngressResult.Accepted)); + return refineryRaw; + } + + private static bool TryFindHarvester(EcoHost host, out EntityId harvester) + { + UnitState[] units = host.Entities.RawUnits; + for (int i = 0; i < host.Entities.Capacity; i++) + { + if (units[i].IsActive && units[i].PlayerId == 0 && units[i].Role == UnitRole.Harvester) + { + harvester = units[i].Id; + return true; + } + } + harvester = EntityId.Invalid; + return false; + } + + [Test] + public void ProducedHarvester_DeliversCredits_WithoutAnyHarvestCommand() + { + var host = EcoHost.CreateWithProduction(Seed); + SetUpRefineryAndQueueOneHarvester(host); + + int recordsSealed = 0; + bool charged = false; + long credits = 0; + for (int tick = 0; tick < 1500; tick++) + { + recordsSealed += host.StepTick().Count; + credits = host.Economy.GetPlayerEconomy(0).AetheriumCredits; + if (credits == 300L) charged = true; + if (charged && credits > 300L) break; + } + + Assert.That(charged, Is.True, "the QueueUnit charged the Harvester's 700 AE"); + Assert.That(recordsSealed, Is.EqualTo(1), + "the QueueUnit is the only command of the whole run — no Harvest, no Move"); + Assert.That(credits, Is.EqualTo(300L + 330L), + "1.000 - 700 for the Harvester, then one full Alliance cargo of 330 AE banked"); + Assert.That(TryFindHarvester(host, out EntityId harvester), Is.True); + Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)1), + "the birth order is retained through the auto-cycle"); + Assert.That(host.Economy.TryGetField(1, out AetheriumField field), Is.True); + Assert.That(field.RemainingAE, Is.LessThanOrEqualTo(9000L - 330L)); + } + + [Test] + public void Stop_OnAProducedHarvester_ClearsTheBirthOrder_AndNothingReassignsIt() + { + var host = EcoHost.CreateWithProduction(Seed); + SetUpRefineryAndQueueOneHarvester(host); + + EntityId harvester = EntityId.Invalid; + for (int tick = 0; tick < 400 && !harvester.IsValid; tick++) + { + host.StepTick(); + TryFindHarvester(host, out harvester); + } + Assert.That(harvester.IsValid, Is.True, "the Refinery produced its Harvester"); + Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)1), + "born with the order"); + + uint raw = UnitCommandStateView.ToRawEntityId(harvester); + Assert.That( + host.Ingress.TrySubmitIntent(CommandIntent.Create(new StopPayload(new[] { raw })), out _), + Is.EqualTo(CommandIngressResult.Accepted)); + host.StepTick(); + host.StepTick(); + Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)0), + "Stop cancels the birth order like any other standing order"); + + long credits = host.Economy.GetPlayerEconomy(0).AetheriumCredits; + for (int tick = 0; tick < 600; tick++) + { + host.StepTick(); + Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)0), + $"nothing re-assigns a stopped harvester (tick {host.Kernel.CurrentTick.Value}) — the order is a birth order only"); + } + Assert.That(host.Entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(0)); + Assert.That(host.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(credits), + "a stopped harvester earns nothing until the player orders it again"); + } } } diff --git a/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs index 58cc4ec5..52addc93 100644 --- a/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs @@ -5,6 +5,7 @@ using Nova.Simulation.Construction; using Nova.Simulation.Definitions; using Nova.Simulation.Economy; +using Nova.Simulation.Pathfinding; using Nova.Simulation.Production; using Nova.Simulation.Snapshots; using Nova.Simulation.State; @@ -15,7 +16,8 @@ namespace Nova.Simulation.Tests /// Canonical production suite (EditMode lane): QueueUnit cost and validation /// (producer assignment, T2 gating, queue capacity, count-scaled /// affordability), exact Q16.16 progress with the low-power halving, - /// deterministic rally-point spawning, CancelProduction refunds, the + /// deterministic rally-point spawning, the Harvester born with a harvest + /// order (#134, D-121), CancelProduction refunds, the /// entity-store-cap pause and the snapshot block 106 v1 contract. All /// values are documented Q-040 provisionals of SimDefinitions. MS-1 has /// no research tree: the retired ResearchTreeSystem scaffolding is @@ -71,6 +73,41 @@ public void Step(int ticks) { for (int i = 0; i < ticks; i++) Kernel.StepTick(); } + + /// + /// Places a completed HQ at (40,40) — power for the Refinery's 20 + /// draw and the storage room a harvest needs — and a completed + /// Alliance Refinery at origin (20,20), and returns the Refinery's + /// raw wire id. Footprint (20,20)-(22,22), centre cell (21,21), + /// default rally (23,21), first spawn cell (19,19) (#134). + /// + public uint SpawnRefinery(byte slot) + { + Assert.That(Construction.PlaceCompletedBuilding(slot, 3, 40, 40).IsValid, Is.True, "HQ"); + EntityId id = Construction.PlaceCompletedBuilding(slot, 4, 20, 20); + Assert.That(id.IsValid, Is.True, "Refinery"); + return UnitCommandStateView.ToRawEntityId(id); + } + + /// + /// Drains a reserve-1 field through a temporary harvester standing + /// on it, then removes that harvester again — the field stays + /// registered but exhausted. Needs the HQ's storage room + /// ( first), or the gather is held. + /// + public void ExhaustField(ushort fieldId, int cellX, int cellY) + { + EntityId drain = Entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(cellX), SimFixed.FromInt(cellY)), + SimFixed.FromInt(2), + role: UnitRole.Harvester); + Entities.GetUnitRef(drain).HarvestFieldId = fieldId; + Step(1); + Assert.That(Economy.TryGetField(fieldId, out AetheriumField field) && field.IsExhausted, Is.True, + $"field {fieldId} must be exhausted before the test proper"); + Assert.That(Entities.DespawnUnit(drain), Is.True); + } } [Test] @@ -477,6 +514,129 @@ public void QueueUnit_ForeignFactionDefinition_IsRejectedInvalidTarget() Assert.That(f.Economy.GetPlayerEconomy(1).AetheriumCredits, Is.EqualTo(1000L)); } + // ------------------------------------------------------------------ + // #134 (D-121): a produced Harvester is born harvesting + // ------------------------------------------------------------------ + + [Test] + public void ProducedHarvester_IsBornWithAnOrderOnTheNearestFieldWithReserve() + { + // Measured from the Refinery's footprint centre (21,21), the same + // anchor and rule as the founding grant (#43): field 1 is + // registered first but lies 39 cells away, field 2 at Chebyshev 6, + // field 3 at 9. + var f = new Fixture(configure: e => + { + Assert.That(e.TryAddField(1, new GridPos2D(60, 60), 9000), Is.True); + Assert.That(e.TryAddField(2, new GridPos2D(21, 27), 9000), Is.True); + Assert.That(e.TryAddField(3, new GridPos2D(30, 21), 9000), Is.True); + }); + uint refinery = f.SpawnRefinery(0); + Assert.That(f.Production.TryQueueUnit(0, refinery, 2, 1), Is.True, "Alliance Harvester (def 2, 700 AE)"); + + f.Step(249); + Assert.That(CountRole(f, UnitRole.Harvester), Is.EqualTo(0), "one tick short of 250"); + f.Step(1); + Assert.That(CountRole(f, UnitRole.Harvester), Is.EqualTo(1), "spawned after exactly 250 full-power ticks"); + + ref readonly UnitState harvester = ref f.Entities.GetUnitRef(FindRole(f, UnitRole.Harvester)); + Assert.That(harvester.HarvestFieldId, Is.EqualTo((ushort)2), + "born with a standing harvest order on the nearest field with reserve — no Harvest command needed"); + Assert.That(harvester.IsReturningCargo, Is.False); + Assert.That(harvester.IsMoving, Is.True, "the rally order is kept: the harvester still walks out first"); + Assert.That(harvester.GoalGridPos.X, Is.EqualTo(23), "default rally two cells east of the centre cell"); + Assert.That(harvester.GoalGridPos.Y, Is.EqualTo(21)); + } + + [Test] + public void ProducedHarvester_FieldTie_ResolvesToTheLowerRegistrationIndex() + { + // Both fields sit at Chebyshev 6 from the centre cell (21,21). + // Field 7 is registered FIRST: the tie goes to the registration + // index, not to the smaller id. + var f = new Fixture(configure: e => + { + Assert.That(e.TryAddField(7, new GridPos2D(27, 21), 9000), Is.True); + Assert.That(e.TryAddField(4, new GridPos2D(21, 27), 9000), Is.True); + }); + uint refinery = f.SpawnRefinery(0); + Assert.That(f.Production.TryQueueUnit(0, refinery, 2, 1), Is.True); + + f.Step(250); + + ref readonly UnitState harvester = ref f.Entities.GetUnitRef(FindRole(f, UnitRole.Harvester)); + Assert.That(harvester.HarvestFieldId, Is.EqualTo((ushort)7), + "a distance tie resolves to the lower registration index (EconomySystem.TryFindNearestField)"); + } + + [Test] + public void ProducedHarvester_SkipsAnExhaustedField() + { + // Field 1 is the nearest (Chebyshev 4) but exhausted; field 2 + // (Chebyshev 9) still holds reserve and wins. + var f = new Fixture(configure: e => + { + Assert.That(e.TryAddField(1, new GridPos2D(21, 25), 1), Is.True); + Assert.That(e.TryAddField(2, new GridPos2D(30, 30), 9000), Is.True); + }); + uint refinery = f.SpawnRefinery(0); + f.ExhaustField(1, 21, 25); + Assert.That(f.Production.TryQueueUnit(0, refinery, 2, 1), Is.True); + + f.Step(250); + + ref readonly UnitState harvester = ref f.Entities.GetUnitRef(FindRole(f, UnitRole.Harvester)); + Assert.That(harvester.HarvestFieldId, Is.EqualTo((ushort)2), + "an exhausted field is never handed out — its order would resolve on the first tick"); + } + + [Test] + public void ProducedHarvester_WithoutAFieldWithReserve_IsBornWithoutAnOrder_AndTheQueueRuns() + { + var f = new Fixture(startingCredits: 1500, configure: e => + { + Assert.That(e.TryAddField(1, new GridPos2D(21, 25), 1), Is.True); + }); + uint refinery = f.SpawnRefinery(0); + f.ExhaustField(1, 21, 25); + Assert.That(f.Production.TryQueueUnit(0, refinery, 2, 2), Is.True, "two Harvesters, 1.400 AE"); + + f.Step(500); + + Assert.That(CountRole(f, UnitRole.Harvester), Is.EqualTo(2), + "a missing field never holds production up"); + Assert.That(f.Production.TotalQueuedUnits, Is.EqualTo(0)); + UnitState[] units = f.Entities.RawUnits; + for (int i = 0; i < f.Entities.Capacity; i++) + { + if (!units[i].IsActive || units[i].Role != UnitRole.Harvester) continue; + Assert.That(units[i].HarvestFieldId, Is.EqualTo((ushort)0), + "only exhausted fields remain: the harvester is born without an order"); + Assert.That(units[i].GoalGridPos.X, Is.EqualTo(23), "the rally order is issued all the same"); + Assert.That(units[i].GoalGridPos.Y, Is.EqualTo(21)); + } + } + + [Test] + public void ProducedInfantry_GetsNoFieldOrder_AndKeepsItsRallyOrder() + { + var f = new Fixture(configure: e => + { + Assert.That(e.TryAddField(1, new GridPos2D(11, 14), 9000), Is.True); + }); + uint barracks = f.SpawnBarracks(0); // centre (11,11) -> default rally (13,11) + Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); + + f.Step(100); + + ref readonly UnitState infantry = ref f.Entities.GetUnitRef(FindRole(f, UnitRole.BasicInfantry)); + Assert.That(infantry.HarvestFieldId, Is.EqualTo((ushort)0), + "the birth order is the Harvester's alone, even with a field three cells away"); + Assert.That(infantry.IsMoving, Is.True); + Assert.That(infantry.GoalGridPos.X, Is.EqualTo(13)); + Assert.That(infantry.GoalGridPos.Y, Is.EqualTo(11)); + } + private static int CountRole(Fixture f, UnitRole role) { int count = 0; diff --git a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs index b5abf61f..2adc6e25 100644 --- a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs +++ b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs @@ -170,8 +170,23 @@ public static class AiBehaviorId /// plays a non-neutral personality is still "r9" — the personality /// is printed next to this identifier, not folded into it. /// + /// + /// r10 widens which harvesters the economy step sends to the own field + /// (#134, D-121): not only those without any order, but every one that + /// is not returning cargo and holds an order on a DIFFERENT field. A + /// produced Harvester is now born with an order (ProductionSystem, + /// D-121), chosen like the founding grant's by the nearest field to + /// the Refinery — the AI chooses by the nearest field to its HQ. Where + /// the two disagree the gather escort walked the harvester to the AI's + /// field while its order named the other one, and the economy held + /// that out-of-reach order forever. The founding grant could already + /// trigger this on main. No number moved, so + /// is unchanged; the canonical match moves + /// with the production change underneath, not with this rule (both + /// measured on the same head, see CanonicalAiOutcomeTests). + /// /// - public const int Revision = 9; + public const int Revision = 10; /// /// Hash over every value of the shipped profile. Domain-separated like diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs index 65f6398b..12d45b8a 100644 --- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs +++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs @@ -64,11 +64,13 @@ namespace Nova.AI /// Chebyshev reach <= 1 (ConstructionSystem remarks), and a replacement /// Builder is queued at the HQ when none is alive; (3) once the Refinery /// stands, harvesters are queued up to - /// , every idle own - /// harvester receives a Harvest intent on the nearest field THAT STILL + /// , every own + /// harvester that is not returning cargo and is not already ordered onto + /// it receives a Harvest intent on the nearest field THAT STILL /// HOLDS RESERVE (an exhausted one is skipped — without that test the step /// re-issues an order the economy clears again on the same tick, forever; - /// issue #85), and harvesters held out of reach are WALKED into the + /// issue #85; since r10 a harvester born with an order on ANOTHER field is + /// re-tasked too, #134), and harvesters held out of reach are WALKED into the /// economy's reach rule with /// explicit Move intents (gather leg toward a field-and-footprint /// dual-reach cell, return leg toward the footprint — this slice does not @@ -268,7 +270,6 @@ private void Decide(uint tick) int builders = 0, harvesters = 0, combatCount = 0; var combatRaws = new List(); var combatUnits = new List(); - var idleHarvesterRaws = new List(); var harvesterRaws = new List(); var harvesterUnits = new List(); var sites = new List(); @@ -333,10 +334,6 @@ private void Decide(uint tick) harvesters++; harvesterRaws.Add(raw); harvesterUnits.Add(u); - if (u.HarvestFieldId == 0 && !u.IsReturningCargo) - { - idleHarvesterRaws.Add(raw); - } break; default: if (IsCombatRole(u.Role)) @@ -425,7 +422,8 @@ private void Decide(uint tick) } // ---- (4) Economy: keep harvesters queued at the Refinery (the - // D-077 producer), send every idle own harvester to the own field + // D-077 producer), send every own harvester not yet ordered onto + // the own field there (#134: not only the idle ones), // and WALK harvesters into reach with explicit Move intents. // This slice never submits SetRallyPoint; it does what a human // player does and micros the harvesters into the economy's reach @@ -465,11 +463,36 @@ private void Decide(uint tick) } } - if (haveField && idleHarvesterRaws.Count > 0) + // Every own harvester that is not on its return leg and does + // not already hold an order on THIS field receives a Harvest + // intent on it (#134, D-121, behaviour revision 10). Up to r9 + // only harvesters WITHOUT any order qualified. But production + // (#134) and the founding grant (#43) pick their field with + // EconomySystem.TryFindNearestField from the Refinery, while + // this step picks with TryGetOwnFieldCell from the HQ. Where + // the two rules disagree, the escort below walks the harvester + // to THIS field while its order names the other one, and the + // economy holds an out-of-reach order instead of dropping it: + // the harvester stood still with frozen cargo for good. A + // harvester already ordered onto this field is left alone, so + // the intent goes out once per harvester, not every cadence. + if (haveField) { - idleHarvesterRaws.Sort(); - SubmitEntityList(idleHarvesterRaws, - ids => CommandIntent.Create(new HarvestPayload(ids, ownFieldId))); + var harvestersToTask = new List(); + for (int h = 0; h < harvesterUnits.Count; h++) + { + UnitState harvester = harvesterUnits[h]; + if (!harvester.IsReturningCargo && harvester.HarvestFieldId != ownFieldId) + { + harvestersToTask.Add(harvesterRaws[h]); + } + } + if (harvestersToTask.Count > 0) + { + harvestersToTask.Sort(); + SubmitEntityList(harvestersToTask, + ids => CommandIntent.Create(new HarvestPayload(ids, ownFieldId))); + } } // Escort targets: the gather leg wants a cell in harvest reach diff --git a/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs b/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs index a9748cb3..84895d23 100644 --- a/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs @@ -62,6 +62,17 @@ namespace Nova.Simulation.Production /// order to the rally cell (a direct SetTarget write, the same /// write class as the construction push-out's — no command record, no /// new command kind; movement then drives the walk). + /// A produced Harvester is additionally BORN HARVESTING (#134, D-121): + /// the same tick it spawns, its standing harvest order is set to + /// measured from the + /// producer's footprint centre cell — the identical rule and anchor + /// the founding grant (#43) uses, integer Chebyshev distance, exhausted + /// fields skipped, ties to the lower registration index. Same write + /// class as the rally order (direct HarvestFieldId write, no + /// command record). It is a birth order only: nothing re-assigns it + /// later, so Stop and Harvest stay the player's. With no field holding + /// reserve the Harvester is born without an order and production + /// continues unchanged. /// When no free cell exists inside the search range or the entity store /// is full (MS-1 cap 1.024, mvp-v1.json capacity.entityStoreCap), the /// finished unit waits: progress stays clamped at the threshold and the @@ -368,7 +379,8 @@ public void SetRallyPoint(uint buildingRaw, SimFixed targetX, SimFixed targetY) /// refund), then progresses every producer's first entry by the /// owner's exact Q16.16 speed multiplier and spawns finished units at /// the building's footprint with a standing move order to the rally - /// point — in strict ascending row order. + /// point (a Harvester also with its birth harvest order, #134) — in + /// strict ascending row order. /// public void ExecuteTick(Tick tick) { @@ -429,6 +441,22 @@ public void ExecuteTick(Tick tick) _entityManager.GetUnitRef(spawned).SetTarget(new GridPos2D(rallyCellX, rallyCellY)); } + // #134 (D-121): a produced Harvester is born harvesting, + // mirroring the founding grant (#43). Same field rule, + // same anchor: the nearest field with reserve left to + // the producer's footprint centre cell (ties to the lower + // registration index). Without such a field it stays + // unassigned; the rally order above is kept either way. + if (def.Role == UnitRole.Harvester) + { + int centreX = Math.Max(0, SimFixed.WorldToGrid(building.Transform.PositionX)); + int centreY = Math.Max(0, SimFixed.WorldToGrid(building.Transform.PositionY)); + if (_economy.TryFindNearestField(centreX, centreY, out ushort fieldId)) + { + _entityManager.GetUnitRef(spawned).HarvestFieldId = fieldId; + } + } + entry.RemainingCount--; entry.ProgressRaw -= thresholdRaw; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af38f47..65be6ae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -258,6 +258,22 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de sie belegt keine Verbesserung. ### Geändert +- **Ein produzierter Sammler erntet von selbst (#134, D-121-Entwurf).** Bisher + bekam nur der Gründungs-Sammler einen Feldauftrag. Jeder weitere stand am + Sammelpunkt, bis jemand `H` drückte. Jetzt setzt `ProductionSystem` beim Spawn + das nächste Feld mit Restbestand ab dem Footprint-Zentrum der Raffinerie, + dieselbe Regel wie bei der Gründung. `Stop` und `H` bleiben beim Spieler, + nichts vergibt neu. Die KI beauftragt zusätzlich Sammler, deren Auftrag ein + fremdes Feld nennt; vorher konnten sie dauerhaft stehen bleiben. Deshalb + steigt `AiBehaviorId` auf `r10.1E6E7AE3`. Gemessen mit `dotnet test` auf dem + PR-Stand: Die kanonische KI-Partie entscheidet weiter bei Tick 2.726, der + Endzustand wandert von `0x4A861D9FA2E57C6F` auf `0x7AF32DBA56E393E2`. Das + verursacht die Produktionsänderung, r10 allein bewegt ihn nicht. Das + Journal `tools/Nova.AiLab/reports/behavior-log.md` existiert im Repository + nicht; die Messung steht deshalb hier und im PR. Das Drehbuch + `Determinism10000Scenario.cs` beauftragt jeden Sammler genau einmal, der + Guard-Test bleibt unberührt. Kein RulesRevision-Bump: Gemischte alte und + neue Builds fallen in Netzpartien erst als Relay-Desync auf - **Die lebende Dokumentation heißt jetzt Hashkrieg (#14, Stufe 3).** 54 Dateien, 84 Stellen — die Marke in der Prosa unter `docs/**` und in den Wurzeldateien. Zwei Regeln haben den Umfang bestimmt. Erstens **E-3: nur die Marke.** Die diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md index c00a15ca..c0c3a4ea 100644 --- a/docs/production/DecisionLog.md +++ b/docs/production/DecisionLog.md @@ -3662,6 +3662,119 @@ berührt. Ansturm-Gegner ab. Wer ohne Menü startet (AutoStart, Harness), spielt diesen, nicht r8. +### D-121 | Entwurf | Wirtschaft (ein produzierter Sammler wird mit Ernteauftrag geboren) + +**Status:** Entwurf aus dem Issue-Lauf vom 2026-10-17 (#134); verbindlich mit +dem Merge des PR. + +**Kontext:** Seit #43 bekommt der Gründungs-Sammler beim Fertigstellen der +Raffinerie einen Ernteauftrag (`ConstructionSystem.GrantFoundingHarvester`): +das nächste Feld mit Restbestand, gemessen ab dem Footprint-Zentrum der +Raffinerie (`EconomySystem.TryFindNearestField`). Jeder weitere, in der +Raffinerie produzierte Sammler bekam keinen. `ProductionSystem` setzte nur das +Sammelpunkt-Ziel, `HarvestFieldId` blieb 0. Der Sammler stand am Sammelpunkt, +bis der Spieler `H` drückte, ohne Hinweis und ohne Eskorte, weil die +Client-Eskorte nur Sammler mit Feldauftrag begleitet. Die KI hatte das Problem +nicht: Sie schickt untätige Sammler selbst aufs eigene Feld. Benachteiligt war +nur der Mensch. Der Testbericht T-01 erwartet, dass ein Sammler sammelt, sobald +Aetherium in seiner Reichweite liegt. + +Am Code nachgeprüft (`main` @ `a6858d1`) kam ein zweiter Befund dazu: Die KI +wählt ihr Feld anders (nächstes Feld zum HQ, `TryGetOwnFieldCell`) als +Produktion und Gründung (nächstes Feld zur Raffinerie). Wo beide Regeln +auseinanderlaufen, führte die Sammel-Eskorte der KI den Sammler zum KI-Feld, +sein Auftrag nannte aber das andere. Die Wirtschaft hält einen Auftrag außer +Reichweite, statt ihn zu verwerfen. Der Sammler stand dann für den Rest der +Partie mit eingefrorener Ladung. Über den Gründungs-Sammler war das schon auf +`main` möglich. + +**Alternativen:** + +1. **Simulation: der Sammler wird mit Auftrag geboren.** `ProductionSystem` + setzt beim Spawn `HarvestFieldId` nach derselben Regel wie die Gründung. + Gilt gleich für Mensch, KI, Netzpartie und Replay, braucht keinen neuen + Befehl und kein neues Snapshot-Feld. Kosten: Das Simulationsverhalten ändert + sich, ein KI-Pin wandert, und ein neuer Build für alle Tester ist nötig. +2. **Client-Weg: die Eskorte erteilt einmalig einen Harvest-Befehl.** Schon + gebaut im Commit `73f54b5` auf `feat/generals-look-and-feel` + (`_autoAssignedHarvesters` in `RtsDeviceInput.cs`). Ein lokaler Sammler ohne + Auftrag bekommt nach Ende seiner Bewegung einen normalen Harvest-Befehl, auf + das nächste Feld ab seiner eigenen Position (Gleitkomma, euklidisch). Keine + Simulationsänderung, aber: wirkt nur für den lokalen Menschen, die Merker + gehen bei Neustart oder Restore verloren, die Feldregel ist eine dritte neben + Gründung und KI, und es gibt keinen CI-Nachweis, weil Unity-Tests in keiner + CI laufen. Das Verhalten hinge am Client statt an der Simulation, im + Widerspruch zum Gründungsfall. Der CHANGELOG-Eintrag des Commits nennt die + Änderung nicht. +3. **Gewollt lassen und den Hinweis nachliefern:** kein Automatismus, dafür + eine Anzeige „N Sammler ohne Auftrag (H)“ und eine Leerlauftaste. Erklärt + die Lücke, statt sie zu schließen, und der Mensch bleibt gegenüber der KI im + Nachteil. +4. **Wie 1, aber die Feldwahl auf den Raffinerie-Umkreis begrenzen.** Ohne Feld + in der Nähe bliebe der Sammler untätig. Näher an der Formulierung von T-01, + aber eine zweite Regel neben der Gründung, und sie ist nicht gemessen. + +**Entscheidung:** Alternative 1. + +- `ProductionSystem.ExecuteTick` setzt einem produzierten Sammler im + Spawn-Tick `HarvestFieldId` auf `EconomySystem.TryFindNearestField`, ab der + Zentrumszelle des Erzeugers (Footprint-Zentrum der Raffinerie). Ganzzahlige + Chebyshev-Distanz, erschöpfte Felder übersprungen, Gleichstand zum + niedrigeren Registrierungsindex: dieselbe Regel und derselbe Anker wie die + Gründung (#43). +- Ohne Feld mit Restbestand bleibt der Sammler unbeauftragt, und die + Produktion läuft unverändert weiter. +- Der Sammelpunkt-Befehl bleibt. Der Sammler läuft zuerst zum Sammelpunkt. +- Es ist ein Geburtsauftrag, **kein Nachsteuern**: Nichts vergibt später neu. + `Stop` löscht ihn wie jeden anderen Dauerbefehl, `H` setzt ein anderes Feld. + Beides bleibt beim Spieler. +- **KI-Härtung im selben PR:** `SkirmishAiSystem` beauftragt jeden Sammler, der + nicht zurückfährt und nicht schon auf dem KI-Feld steht, nicht nur die ohne + Auftrag. `AiBehaviorId` steigt auf r10, der Profil-Hash bleibt. +- **Kein RulesRevision-Bump.** Snapshot-Layout, Befehlsregister und + `RulesHash64` bleiben unberührt. + +**Begründung:** Die Erwartung ist durch T-01 belegt, die KI erfüllt sie schon, +und der Gründungsfall ist der direkte Präzedenzfall. Die Feldregel existiert +bereits deterministisch und öffentlich, es muss keine neue erfunden werden. Der +Client-Weg löst das Problem nur für einen von zwei Spielern und nur so lange, +wie der Client läuft. Die Härtung der KI gehört dazu, weil die Simulation ab +jetzt mehr Sammler mit Auftrag erzeugt und der Hänger sonst häufiger greift. + +Gemessen auf `a6858d1` mit diesem Stand, headless (`dotnet test`): +- Der Entscheidungstick der kanonischen KI-Partie bleibt 2726. +- Der Endzustand wandert von `0x4A861D9FA2E57C6F` auf `0x7AF32DBA56E393E2`. +- Die Produktionsänderung allein ergibt denselben Wert. r10 allein auf der + alten Produktion lässt den alten Wert stehen. +- Der Pin wandert also durch die Simulation unter der KI, nicht durch r10. + +**Konsequenzen:** + +- **Pin im selben PR.** `CanonicalAiOutcomeTests` wird nach D-101 im selben PR + nachgezogen. D-108 und Sprint 21 R-3 verlangen für diesen Pin dagegen einen + zweiten PR und ein Merge-Fenster mit dem Einheitenstrang. Mit D-114 liegen + beide Stränge beim Inhaber. Der Widerspruch wird im PR genannt, nicht hier + aufgelöst. +- **Determinismus-Drehbuch angepasst, Guard unberührt.** + `Determinism10000Scenario.cs` beauftragt jeden eigenen Sammler genau einmal, + statt „Sammler ohne Auftrag“ zu suchen. So bleibt `Harvest` im Befehlsstrom. + `Determinism10000Tests.cs` bleibt unverändert (Sprint 21 R-2). +- **Neuer Build für alle Tester.** Ohne Fingerprint-Bewegung laufen alte und + neue Builds in Netzpartien gleich an und fallen erst beim ersten + produzierten Sammler als Relay-Desync auf (StateHash alle 50 Ticks). Alte + Relay-Aufzeichnungen scheitern am ersten Checkpoint danach. Mergen im + Merge-Fenster der übrigen simulationsändernden PRs. +- **Ein Mechanismus, nicht zwei.** Wird diese Entscheidung angenommen, entfällt + der `_autoAssignedHarvesters`-Block aus `73f54b5` im Art-PR (P-51). Sonst + laufen zwei Feldregeln nebeneinander. +- **Offen für den Inhaber:** ob die Feldwahl ab der Raffinerie oder ab dem + Sammelpunkt erfolgen soll, und ob eine Reichweitengrenze gelten soll + (Alternative 4). Nach Erschöpfung des Heimatfelds kann ein neuer Sammler + heute quer über die Karte geschickt werden, wie Gründung und KI auch. +- **Die KI nutzt weiter genau ein Feld.** Die Härtung zwingt jeden KI-Sammler + auf das KI-Feld. Ein KI-Ausbau auf mehrere Felder (D-114, Stufe B) braucht + dafür eine eigene Regel. + ## Offene Punkte - Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden. diff --git a/reports/v8.6.0/issue-lauf-2026-10-17/02-issue-134-harvester-auto-harvest.md b/reports/v8.6.0/issue-lauf-2026-10-17/02-issue-134-harvester-auto-harvest.md new file mode 100644 index 00000000..a4e83497 --- /dev/null +++ b/reports/v8.6.0/issue-lauf-2026-10-17/02-issue-134-harvester-auto-harvest.md @@ -0,0 +1,156 @@ +--- +issue: 134 +paket: P-134 +branch: feat/134-harvester-born-harvesting +agent: builder (opus) +datum: 2026-10-17 +status: DONE +--- +# #134: Ein produzierter Sammler wird mit Ernteauftrag geboren, die KI beauftragt fremd beauftragte Sammler neu + +## Aufgabe + +Bisher bekam nur der Gründungs-Sammler (#43) einen Feldauftrag. Jeder in der +Raffinerie produzierte Sammler stand am Sammelpunkt, bis der Spieler `H` +drückte. Paket P-134 setzt den Sim-Weg um: `ProductionSystem` vergibt beim +Spawn das nächste Feld mit Restbestand ab dem Footprint-Zentrum der Raffinerie, +nach derselben Regel wie die Gründung. Dazu kommt die KI-Härtung gegen den +Hänger bei abweichender Feldwahl (r10), das angepasste Determinismus-Drehbuch, +der nachgezogene KI-Pin, Tests in beiden Lanes und der D-121-Entwurf. + +## Was geändert wurde — Files Changed + +- `Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs` — Nach `SpawnUnit`, bei `def.Role == UnitRole.Harvester`, wird die Zentrumszelle aus `building.Transform` bestimmt (Klemmung wie beim Sammelpunkt). Dann `_economy.TryFindNearestField` aufrufen und `HarvestFieldId` setzen; ohne Feld bleibt der Sammler unbeauftragt. Der Sammelpunkt-Befehl bleibt. Klassendoku und Summary von `ExecuteTick` nachgezogen (#134, D-121). +- `Assets/_Project/Scripts/AI/SkirmishAiSystem.cs` — Der Schritt (4) beauftragt jetzt alle Sammler mit `!IsReturningCargo && HarvestFieldId != ownFieldId`, bisher nur die mit `HarvestFieldId == 0`. Die Liste wird gebildet, nachdem `ownFieldId` bekannt ist. Die tote Sammelliste `idleHarvesterRaws` im Entity-Scan entfällt. Klassen-Remarks, Schrittkommentar und Begründung am Aufruf nachgezogen. +- `Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs` — `Revision` von 9 auf 10, mit History-Absatz zu r10. Der Profil-Hash bleibt. +- `tools/Nova.SimRunner/Determinism10000Scenario.cs` — Ein `HashSet HarvestersOrdered` in `SlotState` ersetzt `IdleHarvesterRaws` durch `UnorderedHarvesterRaws`: Jeder eigene Sammler wird genau einmal beauftragt, also bleibt `CommandKind.Harvest` im Befehlsstrom. Doku an Klasse, `SlotState` und `IssueSlotCommands` angepasst. Der Guard-Test `Determinism10000Tests.cs` ist unverändert. +- `tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs` — `PinnedEndState` von `0x4A861D9FA2E57C6F` auf `0x7AF32DBA56E393E2`. Im Kommentar stehen „last moved by #134 (D-121)“, der Vorwert, der Vorvorwert und die Messung mit und ohne r10. Der veraltete Hinweis „AiBehaviorId is r7.E34435F9“ ist auf r10.1E6E7AE3 berichtigt. Der Tick 2726 bleibt. +- `tools/Nova.SimRunner.Tests/SkirmishAiTests.cs` — Identitätspin auf `r10.1E6E7AE3` mit kurzem r10-Kommentar. Neuer Helfer `FirstHarvester` und neuer Regressionstest `SkirmishAi_RetasksAHarvesterOrderedOntoAForeignField_AndItMines`. +- `Assets/Tests/EditMode/AI/SkirmishAiTests.cs` — Spiegel: `FirstHarvester` und derselbe Regressionstest. Der EditMode-Spiegel hat keinen Identitätspin, deshalb dort keine r10-Zeile. +- `tools/Nova.SimRunner.Tests/ProductionSystemTests.cs` — Fixture-Helfer `SpawnRefinery` und `ExhaustField` sowie fünf neue Tests. Abgedeckt sind: nächstes Feld, Gleichstand zum Registrierungsindex, erschöpftes Feld übersprungen, kein Feld → kein Auftrag bei laufender Produktion, Infanterie ohne Auftrag mit Sammelpunkt-Befehl. Dazu `using Nova.Simulation.Pathfinding` und die Klassen-Summary. +- `Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs` — 1:1-Spiegel. Der Diff zur .NET-Lane umfasst nur die vier bestehenden Lane-Zeilen (Namespace, Summary, sealed). +- `tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs` — Neue Fabrik `EcoHost.CreateWithProduction` (Verdrahtung wie `ProductionConstructionIntegrationTests`) und zwei Tests. Erstens fließen Credits ohne jeden Harvest-Befehl; der einzige Befehl ist die QueueUnit. Zweitens löscht `Stop` den Geburtsauftrag, und 600 Ticks lang vergibt nichts neu. +- `Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs` — 1:1-Spiegel. **Außerhalb des Schreibumfangs der Paketspezifikation**: Die Spezifikation nennt nur die .NET-Datei. Die Rahmenvorgabe verlangt aber den EditMode-Spiegel, wo der Bestand einen hat, und die Spiegelregel des Repos ebenso. Ich habe ihn deshalb mitgezogen. +- `docs/production/DecisionLog.md` — `### D-121 | Entwurf | Wirtschaft (…)` nach D-114, vor „## Offene Punkte“. Kopfversion und Änderungsverlauf unberührt. +- `CHANGELOG.md` — ein Absatz unter `## [Unreleased]` → `### Geändert`, mit (#134). + +Nicht angefasst: `RtsDeviceInput.cs`, `BuildMenuHud.cs`, die vier Guard-Dateien, `MatchFingerprint.cs`, `docs/README.md`, `ScopeLedger.md`. + +## Tests und Prüfungen + +Alle Befehle im Worktree, mit +`export DOTNET_ROOT="/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet"; export PATH="$DOTNET_ROOT:$PATH"`. + +| Befehl | Ergebnis | +|---|---| +| `dotnet test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release` (Basis a6858d1, vor jeder Änderung) | 778/778 grün | +| derselbe Befehl, Endstand | **786/786 grün** (778 + 8 neue Tests) | +| `… --no-build --filter "FullyQualifiedName~Determinism10000Tests"` | 10/10 grün; die Guard-Datei ist unverändert, `Harvest` steht weiter im Strom | +| `python3 .github/scripts/check_docs.py` | OK, 203 Markdown-Dateien und 5 Quality-JSONs, exit 0 (nur die bekannten Notices zu fehlenden Status-Kopfzeilen) | +| `python3 .github/scripts/check_baseline_guard.py --self-test` | passed, inklusive Negativkontrolle | +| `check_baseline_guard.py --changed-files --labels ` | „Baseline guard passed: the PR changes only one side of the separation.“ | +| `git diff --name-only` gegen die vier Guard-Dateien | keine Treffer | + +**Gegenproben in Scratch-Kopien** (`scratchpad/build-134/prodonly` und `noprod`; rsync des Worktrees, der Worktree selbst blieb unberührt): + +- Neue Produktion, **alte KI** (r9-Code), neue Tests: 785/786. Rot ist genau `SkirmishAi_RetasksAHarvesterOrderedOntoAForeignField_AndItMines`. Der Regressionstest erkennt den Hänger also. +- **Alte Produktion**, neue KI: 780/786. Rot sind die fünf Geburtsauftrags-Tests (3× ProductionSystem, 2× EconomyIntegration) und der KI-Pin. Der Pin zeigt dort `0x4A861D9FA2E57C6F`, also den alten Wert: r10 allein bewegt die kanonische Partie nicht. +- Neue Produktion mit alter KI ergibt beim KI-Pin `0x7AF32DBA56E393E2`, also denselben Wert wie der Endstand. Der Pin wandert durch die Produktionsänderung. +- In allen drei Varianten blieb der Entscheidungstick 2726 (im `Assert.Multiple` schlug nur der Hash fehl). + +**Nicht gelaufen:** + +- Unity-EditMode-Tests: Die Spiegel in `Assets/Tests/EditMode/AI/SkirmishAiTests.cs`, `Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs` und `Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs` sind weder kompiliert noch ausgeführt. Grund: Unity ist für dieses Paket nicht freigegeben und läuft in keiner CI. Sie sind textgleich zur .NET-Lane bis auf die bestehenden Lane-Unterschiede; das per `diff` geprüft. +- PlayMode-Tests: nicht gelaufen, nicht betroffen. +- Gespielte Runde (zweiten Sammler bauen, er fährt ohne `H` los): **nicht gespielt**. Der Client-Teil (Eskorte ab Sammelpunkt zum Feld) ist nur durch den unveränderten Code von `RtsDeviceInput` begründet, nicht beobachtet. + +## Entscheidung + +D-121-Entwurf (`docs/production/DecisionLog.md`, „### D-121 | Entwurf | Wirtschaft (ein produzierter Sammler wird mit Ernteauftrag geboren)“): + +- Ein produzierter Sammler bekommt im Spawn-Tick `HarvestFieldId` = `TryFindNearestField` ab dem Footprint-Zentrum der Raffinerie, nach Regel und Anker der Gründung. +- Es gibt kein Nachsteuern. Stop und H bleiben beim Spieler, und ohne Feld mit Restbestand gibt es keinen Auftrag. +- Die KI-Härtung (r10) kommt im selben PR, ohne RulesRevision-Bump. + +Vier bewertete Alternativen: Sim-Weg (gewählt), der Client-Weg aus `73f54b5`, gewollt lassen mit Hinweis-UI, und der Sim-Weg mit Reichweitengrenze. Die Konsequenzen nennen den Pin im selben PR samt Widerspruch D-101 ↔ D-108/R-3, das Drehbuch, das Merge-Fenster und den neuen Build, den Wegfall des Client-Blocks in P-51 sowie die KI-Ein-Feld-Grenze. + +## Nebenbefunde (nicht umgesetzt) + +1. Die Klassendoku von `ProductionSystem` behauptet weiter „HQ produces Builder and Harvester“ (QueueUnit-Absatz). Seit D-077 produziert die Raffinerie den Sammler. Die Zeile ist veraltet, nicht von #134 verursacht und unangetastet. +2. Der `_autoAssignedHarvesters`-Block in `RtsDeviceInput.cs` (Art-Branch, `73f54b5`) wird mit diesem PR zu totem Ballast mit abweichender Feldregel (Gleitkomma, ab Sammlerposition). Sein CHANGELOG-Eintrag fehlt; vorgesehen ist die Entfernung in P-51. +3. `docs/production/DemoRunbook.md:61-62` und `:140` beschreiben „Harvester erntet das Feld“ nach Q. Auf `main` war das bisher faktisch falsch, mit diesem PR stimmt es. Kein Doku-Eingriff nötig. +4. Der Onboarding-Text in `BuildMenuHud.cs:85-86` nennt `H` als Schritt 3. Mit diesem PR ist der Schritt für neue Sammler überflüssig. Bewusst nicht angefasst, weil der Art-Branch die Datei umbaut. +5. `tools/Nova.AiLab/reports/behavior-log.md` existiert nicht, obwohl `AiBehaviorId`/`SkirmishAiTests` dorthin verweisen. Die r10-Messung steht deshalb im CHANGELOG und im PR. +6. Der KI-Harness (`SkirmishAiTests`) hat nur zwei Felder mit je 2 Mio. AE. Der kanonische Pin deckt das Ausweichen nach Erschöpfung nicht ab, das tun nur die neuen Unit-Tests. +7. `.gitignore:26` lautet `reports/ # lokale …`. Git kennt keine Kommentare am Zeilenende, deshalb greift das Muster nicht: `git status` zeigt `reports/v8.6.0/issue-lauf-2026-10-17/` als unversioniert (`??`), nicht als ignoriert. Der Bericht taucht daher ohne `-f` in `git status` auf. +8. Die KI-Härtung zwingt jeden KI-Sammler auf genau ein KI-Feld. Für D-114 Stufe B (Ausbau auf mehrere Felder) braucht es eine eigene Regel, zum Beispiel eine Gather-Zelle je Feld. + +## Offene Fragen an den Inhaber + +1. Sim-Weg (dieser PR) oder Client-Weg (`73f54b5`)? Gewinnt der Sim-Weg, entfällt der `_autoAssignedHarvesters`-Block im Art-PR (P-51). +2. Soll die Feldwahl ab dem Raffineriezentrum (umgesetzt) oder ab der Sammelpunktzelle erfolgen? Und soll es eine Reichweitengrenze geben (T-01 spricht von „in seiner Reichweite“; heute kann ein neuer Sammler nach Erschöpfung des Heimatfelds quer über die Karte geschickt werden)? +3. Pin-Hoheit: D-101 (Pin im selben PR) gegen D-108/Sprint 21 R-3 (zweiter PR, Merge-Fenster mit dem Einheitenstrang). Umgesetzt ist D-101, der Widerspruch gehört in den PR-Text. +4. Merge-Fenster: Am besten zusammen mit der RulesRevision-V4 des Art-Branches mergen, damit der Fingerprint alte Builds abtrennt. Wer als Zweiter mergt, misst den KI-Pin neu (der Art-Branch bewegt ihn selbst auf 2721/0x6E15320BCC3F3EE4, laut Paketspezifikation; von mir nicht gemessen). +5. D-ID-Endnummer: vorläufig D-121, die Vergabe macht der Integrator. + +## Vorschlag für Commit-Titel und PR-Beschreibung + +**Commit-Titel:** `feat(economy): let produced harvesters start with a field order` + +**PR-Beschreibung:** + +> **Was:** Ein in der Raffinerie produzierter Sammler bekommt beim Spawn den +> Ernteauftrag auf das nächste Feld mit Restbestand (ab dem Footprint-Zentrum +> der Raffinerie), wie der Gründungs-Sammler seit #43. Die Skirmish-KI +> beauftragt zusätzlich Sammler, deren Auftrag ein fremdes Feld nennt +> (AiBehaviorId r10.1E6E7AE3). Fixes #134. +> +> **Warum:** Nur der Gründungs-Sammler erntete von selbst. Jeder weitere wartete +> unbewacht auf ein `H`, während die KI ihre Sammler längst selbst schickte. +> Ohne die KI-Härtung bleibt ein Sammler mit abweichendem Feldauftrag dauerhaft +> stehen. Möglich war das schon auf `main` über den Gründungs-Sammler. D-121 +> (Entwurf) hält Regel, Alternativen und Konsequenzen fest, darunter den +> Client-Weg aus `73f54b5` als Alternative. +> +> **Bereiche:** `Simulation/Production`, `AI`, `AI.Data`, Drehbuch +> `tools/Nova.SimRunner/Determinism10000Scenario.cs`, Tests in beiden Lanes, +> DecisionLog, CHANGELOG. +> +> **Pins:** +> - `CanonicalAiOutcomeTests`: Tick 2726 bleibt, der Endzustand geht von +> `0x4A861D9FA2E57C6F` auf `0x7AF32DBA56E393E2`. Gemessen auf a6858d1 plus +> dieser PR. Die Produktionsänderung allein liefert denselben Wert, r10 allein +> den alten. +> - Nach D-101 im selben PR nachgezogen. Hinweis: D-108/Sprint 21 R-3 verlangen +> für diesen Pin einen zweiten PR; seit D-114 liegen beide Stränge beim +> Inhaber. +> - Keine der vier Guard-Dateien ist geändert. `check_baseline_guard` ist grün. +> Das Drehbuch statt des Guard-Tests wurde angepasst (Sprint 21 R-2). +> +> **Changelog:** `[Unreleased]` → Geändert: „Ein produzierter Sammler erntet von +> selbst (#134, D-121-Entwurf).“ +> +> **Tests:** +> - `dotnet test … -c Release`: 786/786 grün (Basis 778). +> - `check_docs.py` OK, `check_baseline_guard.py --self-test` passed. +> +> **Unity-Spuren:** Die EditMode-Spiegel (AI/SkirmishAiTests, +> Simulation/ProductionSystemTests, Simulation/EconomyIntegrationTests) sind +> **nicht gelaufen**; lokal in Unity 6000.5.4f1 auszuführen. PlayMode ist +> nicht betroffen. +> +> **Spielabnahme:** **nicht gespielt.** Ersatznachweise: +> - Headless belegen die Integrationstests den vollen Zyklus ohne +> Harvest-Befehl. +> - Sie belegen auch, dass `Stop` den Auftrag löscht und nichts neu vergibt. +> +> Restrisiko: +> - Der Client-Teil (Eskorte vom Sammelpunkt zum Feld) ist nicht beobachtet. +> - Der Umweg über einen weit gesetzten Sammelpunkt ist nicht beobachtet. +> - Ebenso nicht beobachtet: das Zusammenspiel mit dem Client-Block aus +> `73f54b5`, falls beide gleichzeitig landen. +> - Gemischte alte und neue Builds fallen in Netzpartien erst als Relay-Desync +> auf (kein RulesRevision-Bump). Deshalb im Merge-Fenster mergen und danach +> neuen Testbuild ausgeben. +> +> 🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/reports/v8.6.0/issue-lauf-2026-10-17/02b-review-1-issue-134.md b/reports/v8.6.0/issue-lauf-2026-10-17/02b-review-1-issue-134.md new file mode 100644 index 00000000..8582207a --- /dev/null +++ b/reports/v8.6.0/issue-lauf-2026-10-17/02b-review-1-issue-134.md @@ -0,0 +1,168 @@ +--- +issue: 134 +paket: P-134 +reviewer: opus +runde: 1 +verdict: APPROVED +datum: 2026-10-17 +--- +# Review Runde 1: #134, ein produzierter Sammler wird mit Ernteauftrag geboren + +Unabhängiges Read-only-Review nach AGENTS.md §3. Geprüft wurde der unkommittierte +Stand im Worktree `feat-134` (Branch `feat/134-harvester-born-harvesting`, Basis +`a6858d1`). Ich habe keine Projektdatei geändert. Geschrieben habe ich nur diesen +Bericht und Scratch-Kopien unter `scratchpad/build-134/review/`. + +## Was geprüft wurde + +- `git status --short` und `git diff` vollständig: 13 geänderte Dateien, dazu der + untrackte Builder-Bericht. Abgeglichen mit Issue #134, der Paketspezifikation + P-134 und den Belegen der Triage. +- Schreibumfang: Alle Dateien liegen im Umfang der Spezifikation. Einzige + Ausnahme ist `Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs`, der + EditMode-Spiegel der .NET-Datei. Der Builder weist ihn offen aus, und die + Spiegelregel deckt ihn. `RtsDeviceInput.cs`, `BuildMenuHud.cs`, + `MatchFingerprint.cs`, `docs/README.md` und `ScopeLedger.md` sind unberührt. +- Die vier Guard-Dateien: `git diff --stat` darauf ist leer. + `Determinism10000Tests.cs` pinnt keinen Hash, prüft aber `CommandKind.Harvest` + im Strom (Zeilen 118 und 121). Er ist grün. +- Fachlich: Regel und Anker gegen `ConstructionSystem.GrantFoundingHarvester` + (Zeilen 1050–1068) verglichen. `building.Transform` steht auf `origin + 1` + (`SpawnBuildingEntity`, Zeile 1416), und `BuildingFootprintCells` ist 3, + also `centre = 1`. Damit ist der Anker identisch mit `originX + centre`. + `TryFindNearestField` (EconomySystem.cs:386) rechnet ganzzahlig mit + Chebyshev-Distanz, überspringt erschöpfte Felder und löst Gleichstand zum + niedrigeren Index auf. +- Determinismus: kein float/double in den geänderten Simulationsdateien. Das + `HashSet` im Drehbuch nimmt nur `Add` auf und wird nie iteriert. Die + KI-Liste wird in Entity-Index-Reihenfolge gebaut und danach sortiert. Es gibt + keine Zeit- oder Zufallsquellen. `HarvestFieldId` ist schon Teil des + Snapshots, das Layout ändert sich nicht. +- Client-Seite auf `main`: Die Eskorte in `RtsDeviceInput.cs` (ab Zeile 740) + greift bei `HarvestFieldId != 0`, sobald der Sammler stillsteht. Der + Sammelpunkt-Lauf und die anschließende Eskorte passen also zusammen. Das ist + nur gelesen, nicht gespielt. + +## Nachgerechnete Ergebnisse (selbst gemessen) + +| Befehl | Ergebnis | +|---|---| +| `dotnet test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release` im Worktree | 786/786 grün, 0 Fehler | +| `python3 .github/scripts/check_docs.py` | OK, 204 Markdown-Dateien und 5 Quality-JSONs, exit 0 | +| `python3 .github/scripts/check_baseline_guard.py --self-test` | passed, inklusive Negativkontrolle | +| `check_baseline_guard.py --changed-files --labels ` | „Baseline guard passed: the PR changes only one side of the separation.“, exit 0 | +| Gegenprobe A: Worktree-Kopie mit `SkirmishAiSystem.cs` von `a6858d1` (neue Produktion, alte KI-Regel) | 785/786. Rot ist nur `SkirmishAi_RetasksAHarvesterOrderedOntoAForeignField_AndItMines`. Der KI-Pin ist dort grün, steht also auf `0x7AF32DBA56E393E2` | +| Gegenprobe B: Worktree-Kopie mit `ProductionSystem.cs` von `a6858d1` (alte Produktion, r10) | 780/786. Rot sind der KI-Pin („But was 0x4A861D9FA2E57C6F“), 3 ProductionSystem-Tests und 2 EconomyIntegration-Tests | + +Die Zahl 203 im Builder-Bericht für `check_docs` passt zu meiner 204: +`check_docs.py` zählt auch untrackte Dateien (`git ls-files --others +--exclude-standard`), und `reports/` wird nicht ignoriert (Nebenbefund 7 des +Builders, mit `git check-ignore` bestätigt). Der Builder hat vor seinem Bericht +gemessen. Die Basis 778 habe ich nicht gesondert auf `a6858d1` gemessen. Sie +ergibt sich aber aus 786 minus 8 neuen Tests (5 + 2 + 1, im Diff gezählt). + +**Bericht ehrlich:** ja. Alle Zahlen, die ich nachgemessen habe, stimmen: 786, +die beiden Gegenproben 785 und 780, alter und neuer Pin, Tick 2726. Nicht +Gelaufenes ist ausgewiesen: EditMode, PlayMode und die Spielabnahme. + +## Befunde + +### Hoch + +Keine. + +### Mittel + +1. **Kein RulesRevision-Bump bei geändertem Simulationsverhalten.** Betroffen + sind `docs/production/DecisionLog.md` (D-121, Konsequenzen) und + `Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs:127` + (unverändert V3). Alte und neue Builds handshaken weiter. Sie laufen erst + beim ersten produzierten Sammler auseinander und fallen dann als + Relay-Desync auf. D-104 hat für einen vergleichbaren Fall bewusst eine + Revision angehängt. Die Spezifikation verlangt ausdrücklich „kein Bump“, + und D-121 nennt die Folge offen, deshalb blockiert das nicht. Der Inhaber + sollte aber bewusst wählen: entweder im Merge-Fenster mit der RulesRevision + V4 des Art-Branches mergen, wie der Builder vorschlägt, oder hier selbst V4 + anhängen. + +### Niedrig + +2. **CHANGELOG.md:261–276.** Der Absatz endet ohne Punkt („…als Relay-Desync + auf“) und ist mit 16 Zeilen eher ein Bericht als ein kurzer Absatz. + Vorschlag: Punkt setzen und die Pin-Messung dem PR-Text überlassen. +3. **tools/Nova.SimRunner.Tests/SkirmishAiTests.cs:623–630.** Der Kommentar + direkt darüber sagt, ein Revisionssprung bei unverändertem Profil-Hash + „would have meant a rule nobody can switch off“. r10 ist genau so ein Fall. + Der r10-Zusatz geht darauf nicht ein. In der Sache ist das vertretbar, weil + r10 einen Hänger behebt und keine Strategieregel einführt. Ein Halbsatz + dazu würde den Widerspruch im Testkommentar auflösen. +4. **Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs:170.** Der r9-Absatz + sagt weiter, eine Partie mit nicht-neutraler Persönlichkeit sei „still r9“. + Ab jetzt heißt das „r10“. Der Satz ist historisch gemeint, liest sich aber + als Gegenwart. +5. **Testabdeckung des Spielerwegs.** + `ProducedHarvester_DeliversCredits_WithoutAnyHarvestCommand` legt das Feld + bewusst in Erntereichweite des Standard-Sammelpunkts (24,22 gegen 23,21). + Damit beweist der Test den Geburtsauftrag und den Wirtschaftszyklus, aber + nicht den Weg Sammelpunkt → entferntes Feld. Der hängt an der + Client-Eskorte, und die ist ungetestet und ungespielt. Der Builder nennt + das als Restrisiko. Kein Fehler, nur zur Einordnung des Nachweises. +6. **Veraltete Doku außerhalb des Diffs.** Nebenbefund 1 des Builders ist + bestätigt: `ProductionSystem.cs:38` sagt „HQ produces Builder and + Harvester“, seit D-077 stimmt das nicht mehr. Das gehört nicht in diesen PR. + +## Spezifikationsabgleich + +- ProductionSystem: Geburtsauftrag, gleiche Klemmung wie der Sammelpunkt, ohne + Feld kein Auftrag, Klassendoku nachgezogen. Umgesetzt. +- SkirmishAiSystem: Beauftragt werden `!IsReturningCargo && HarvestFieldId != + ownFieldId`. Remarks und Schrittkommentar sind nachgezogen, die tote Liste + `idleHarvesterRaws` ist entfernt. Umgesetzt. +- AiBehaviorId Revision 10 mit History-Absatz, Profil-Hash unverändert, + Identitätspin `r10.1E6E7AE3`. Umgesetzt. +- Determinism10000Scenario: Jeder Sammler wird genau einmal beauftragt, über + ein `HashSet` in `SlotState`. `IdleHarvesterRaws` ist ersetzt, die Doku + angepasst, der Guard unberührt und grün. Umgesetzt. Neu wird jetzt auch der + Gründungs-Sammler einmal beauftragt, den das alte Drehbuch ausließ. Das ist + harmlos und deterministisch, die Replay-Wiedergabe nutzt den aufgezeichneten + Strom. +- CanonicalAiOutcomeTests: Der Pin ist mit Vorwert, Vorvorwert und „last moved + by #134“ nachgezogen. Der veraltete r7-Hinweis ist berichtigt, Tick 2726 + bleibt. Umgesetzt und von mir gegengemessen. +- Neue Tests der Spezifikation: alle vorhanden. Nächstes Feld, Gleichstand, + erschöpftes Feld, kein Feld, Nicht-Sammler, Stop, Integration ohne + Harvest-Befehl, KI-Regression. Die Tests nageln das Verhalten wirklich fest. + Gegenprobe B lässt die Geburtsauftrags-Tests rot werden, Gegenprobe A den + KI-Regressionstest. +- EditMode-Spiegel: per Diff geprüft, textgleich mit der .NET-Lane. Der + Identitätspin fehlt im Spiegel, weil der EditMode-Bestand keinen hat. + Nicht kompiliert und nicht gelaufen. +- D-121: Kopfzeile im Nachbarformat, Status „Entwurf …“, Kontext, vier + bewertete Alternativen (darunter der Client-Weg `73f54b5`), Entscheidung, + Begründung, Konsequenzen. Steht nach D-114 und vor „## Offene Punkte“. + Kopfversion und Änderungsverlauf sind unberührt. Vollständig. +- CHANGELOG: ein Eintrag unter `[Unreleased]` → `### Geändert`, mit „(#134)“. + Die Kategorie passt. + +## Nicht gelaufen (auch in diesem Review) + +- Unity-EditMode- und PlayMode-Tests. Keine Unity-Freigabe, und Unity läuft in + keiner CI. +- Spielabnahme: zweiten Sammler bauen und beobachten, dass er ohne `H` erntet. + Nicht gespielt. + +## Empfehlung für den Inhaber + +APPROVED. Die Änderung ist korrekt, deterministisch und durch Tests +festgenagelt, die bei Rücknahme rot werden. Der Bericht ist ehrlich. Vor dem +Merge sind zwei Dinge zu entscheiden: + +1. Sim-Weg oder Client-Weg `73f54b5`. Bei Sim-Weg fällt der Client-Block in + P-51 weg. +2. Merge-Fenster und Fingerprint (Befund 1). Mergen zusammen mit der + RulesRevision V4 des Art-Branches, sonst lieber hier eine Revision anhängen. + Wer als Zweiter mergt, misst den KI-Pin neu. + +Die niedrigen Befunde 2 bis 4 lassen sich beim Commit ohne neue Messung +beheben. Im PR muss stehen: „nicht gespielt“ samt Restrisiko Eskorte, und +„EditMode nicht gelaufen“. diff --git a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs index 13c132a0..c6fdaf5f 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs @@ -47,14 +47,20 @@ public sealed class CanonicalAiOutcomeTests private const uint PinnedDecidedTick = 2726u; /// - /// End-state hash of the canonical AI match, last moved by: Sprint 16 - /// package 16.9 / D-104 and D-107. The footprint placement rules and - /// corrected mirrored HQ geometry move the simulated match without - /// changing the AI implementation. - /// AiBehaviorId is r7.E34435F9. - /// Previous value: 0x28F2CC571BCE6B76 (Sprint 16.8). + /// End-state hash of the canonical AI match, last moved by #134 + /// (D-121): a produced Harvester is born with a harvest order on the + /// nearest field to its Refinery. That is a production change under + /// the AI; the decided tick (2726) did not move. The same PR bumps the + /// AI to r10 (re-tasking harvesters ordered onto a foreign field), and + /// the hash was measured both ways on the same head: production change + /// alone and production change plus r10 both give this value — r10 is + /// not what moved it. + /// AiBehaviorId is r10.1E6E7AE3. + /// Previous value: 0x4A861D9FA2E57C6F (Sprint 16.9 / D-104 and D-107, + /// footprint placement rules and corrected mirrored HQ geometry). + /// Before that: 0x28F2CC571BCE6B76 (Sprint 16.8). /// - private const string PinnedEndState = "0x4A861D9FA2E57C6F"; + private const string PinnedEndState = "0x7AF32DBA56E393E2"; [Test] public void CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState() diff --git a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs index 747aa9a3..74a2e66e 100644 --- a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs @@ -7,6 +7,7 @@ using Nova.Simulation.Economy; using Nova.Simulation.Movement; using Nova.Simulation.Pathfinding; +using Nova.Simulation.Production; using Nova.Simulation.Replays; using Nova.Simulation.State; using Nova.Simulation.Vision; @@ -19,7 +20,8 @@ namespace Nova.SimRunner.Tests /// (economy registered BEFORE movement — SimulationCore.md section 2, /// phases 2/3 before 6), two-kernel determinism, hash sensitivity, /// snapshot continuation and replay playback with economy orders, plus - /// the low-power transition through a combat kill. + /// the low-power transition through a combat kill, and the produced + /// Harvester born harvesting (#134, D-121). /// Mirror of the EditMode lane EconomyIntegrationTests. /// [TestFixture] @@ -83,6 +85,43 @@ public static EcoHost Create(ulong seed, int capacity = 256, ushort width = 64, return new EcoHost(kernel, entities, economy, construction, session, ingress); } + /// + /// The same canonical host with construction and production wired + /// in (#134): economy, then construction and production (phases + /// 2/3 before 4/5 before 6), and a command view that resolves + /// QueueUnit — the wiring of + /// ProductionConstructionIntegrationTests. + /// + public static EcoHost CreateWithProduction(ulong seed, int capacity = 256, ushort width = 64, ushort height = 64) + { + var entities = new EntityManager(capacity); + var pathfinding = new PathfindingSystem(width, height); + var movement = new MovementSystem(entities, pathfinding); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); + var production = new ProductionSystem(entities, economy, construction); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, width, height); + var combat = new CombatSystem(entities, fogOfWar, economy, construction); + + var kernel = new SimulationKernel(new SimRandom(seed)); + kernel.RegisterSystem(economy); + kernel.RegisterSystem(construction); + kernel.RegisterSystem(production); + kernel.RegisterSystem(pathfinding); + kernel.RegisterSystem(movement); + kernel.RegisterSystem(fogOfWar); + kernel.RegisterSystem(combat); + + var session = new MatchSession(localSlot: 0, activeSlots: new byte[] { 0, 1 }, inputDelayTicks: 1); + var ingress = new CommandIngress(session); + _ = new LocalLoopbackTransport(ingress); + kernel.BindCommands( + new UnitCommandStateView(entities, pathfinding, economy, construction, production), ingress); + + kernel.Start(); + return new EcoHost(kernel, entities, economy, construction, session, ingress); + } + /// One host lockstep iteration: seal the due batch, submit it, step, advance the session. public CommandBatch StepTick() { @@ -541,5 +580,111 @@ public void Replay_WithHarvestRejections_PlaybackReproducesResultsAndEndHash() Assert.That(playback.Kernel.CalculateStateHash(), Is.EqualTo(endHash), "playback re-executes the rejections to the identical results and end state hash"); } + + // ------------------------------------------------------------------ + // #134 (D-121): a produced Harvester is born harvesting + // ------------------------------------------------------------------ + + /// + /// Completed HQ (power and storage) at (40,40), completed Alliance + /// Refinery at origin (20,20) — footprint (20,20)-(22,22), centre + /// (21,21), default rally (23,21), in deposit reach of the footprint — + /// and one field at (24,22), in harvest reach of that rally cell. Then + /// one Harvester is queued through the sealed command path. Returns + /// the Refinery's raw id. + /// + private static uint SetUpRefineryAndQueueOneHarvester(EcoHost host) + { + Assert.That(host.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ"); + EntityId refinery = host.Construction.PlaceCompletedBuilding(0, 4, 20, 20); + Assert.That(refinery.IsValid, Is.True, "Refinery"); + Assert.That(host.Economy.TryAddField(1, new GridPos2D(24, 22), 9000), Is.True); + uint refineryRaw = UnitCommandStateView.ToRawEntityId(refinery); + Assert.That( + host.Ingress.TrySubmitIntent(CommandIntent.Create(new QueueUnitPayload(refineryRaw, 2, 1)), out _), + Is.EqualTo(CommandIngressResult.Accepted)); + return refineryRaw; + } + + private static bool TryFindHarvester(EcoHost host, out EntityId harvester) + { + UnitState[] units = host.Entities.RawUnits; + for (int i = 0; i < host.Entities.Capacity; i++) + { + if (units[i].IsActive && units[i].PlayerId == 0 && units[i].Role == UnitRole.Harvester) + { + harvester = units[i].Id; + return true; + } + } + harvester = EntityId.Invalid; + return false; + } + + [Test] + public void ProducedHarvester_DeliversCredits_WithoutAnyHarvestCommand() + { + var host = EcoHost.CreateWithProduction(Seed); + SetUpRefineryAndQueueOneHarvester(host); + + int recordsSealed = 0; + bool charged = false; + long credits = 0; + for (int tick = 0; tick < 1500; tick++) + { + recordsSealed += host.StepTick().Count; + credits = host.Economy.GetPlayerEconomy(0).AetheriumCredits; + if (credits == 300L) charged = true; + if (charged && credits > 300L) break; + } + + Assert.That(charged, Is.True, "the QueueUnit charged the Harvester's 700 AE"); + Assert.That(recordsSealed, Is.EqualTo(1), + "the QueueUnit is the only command of the whole run — no Harvest, no Move"); + Assert.That(credits, Is.EqualTo(300L + 330L), + "1.000 - 700 for the Harvester, then one full Alliance cargo of 330 AE banked"); + Assert.That(TryFindHarvester(host, out EntityId harvester), Is.True); + Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)1), + "the birth order is retained through the auto-cycle"); + Assert.That(host.Economy.TryGetField(1, out AetheriumField field), Is.True); + Assert.That(field.RemainingAE, Is.LessThanOrEqualTo(9000L - 330L)); + } + + [Test] + public void Stop_OnAProducedHarvester_ClearsTheBirthOrder_AndNothingReassignsIt() + { + var host = EcoHost.CreateWithProduction(Seed); + SetUpRefineryAndQueueOneHarvester(host); + + EntityId harvester = EntityId.Invalid; + for (int tick = 0; tick < 400 && !harvester.IsValid; tick++) + { + host.StepTick(); + TryFindHarvester(host, out harvester); + } + Assert.That(harvester.IsValid, Is.True, "the Refinery produced its Harvester"); + Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)1), + "born with the order"); + + uint raw = UnitCommandStateView.ToRawEntityId(harvester); + Assert.That( + host.Ingress.TrySubmitIntent(CommandIntent.Create(new StopPayload(new[] { raw })), out _), + Is.EqualTo(CommandIngressResult.Accepted)); + host.StepTick(); + host.StepTick(); + Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)0), + "Stop cancels the birth order like any other standing order"); + + long credits = host.Economy.GetPlayerEconomy(0).AetheriumCredits; + for (int tick = 0; tick < 600; tick++) + { + host.StepTick(); + Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)0), + $"nothing re-assigns a stopped harvester (tick {host.Kernel.CurrentTick.Value}) — the order is a birth order only"); + } + Assert.That(host.Entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(0)); + Assert.That(host.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(credits), + "a stopped harvester earns nothing until the player orders it again"); + } } } diff --git a/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs b/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs index f25f2e8d..97a48f67 100644 --- a/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs @@ -5,6 +5,7 @@ using Nova.Simulation.Construction; using Nova.Simulation.Definitions; using Nova.Simulation.Economy; +using Nova.Simulation.Pathfinding; using Nova.Simulation.Production; using Nova.Simulation.Snapshots; using Nova.Simulation.State; @@ -15,7 +16,8 @@ namespace Nova.SimRunner.Tests /// Canonical production suite (.NET lane): QueueUnit cost and validation /// (producer assignment, T2 gating, queue capacity, count-scaled /// affordability), exact Q16.16 progress with the low-power halving, - /// deterministic rally-point spawning, CancelProduction refunds, the + /// deterministic rally-point spawning, the Harvester born with a harvest + /// order (#134, D-121), CancelProduction refunds, the /// entity-store-cap pause and the snapshot block 106 v1 contract. All /// values are documented Q-040 provisionals of SimDefinitions. MS-1 has /// no research tree: the retired ResearchTreeSystem scaffolding is @@ -71,6 +73,41 @@ public void Step(int ticks) { for (int i = 0; i < ticks; i++) Kernel.StepTick(); } + + /// + /// Places a completed HQ at (40,40) — power for the Refinery's 20 + /// draw and the storage room a harvest needs — and a completed + /// Alliance Refinery at origin (20,20), and returns the Refinery's + /// raw wire id. Footprint (20,20)-(22,22), centre cell (21,21), + /// default rally (23,21), first spawn cell (19,19) (#134). + /// + public uint SpawnRefinery(byte slot) + { + Assert.That(Construction.PlaceCompletedBuilding(slot, 3, 40, 40).IsValid, Is.True, "HQ"); + EntityId id = Construction.PlaceCompletedBuilding(slot, 4, 20, 20); + Assert.That(id.IsValid, Is.True, "Refinery"); + return UnitCommandStateView.ToRawEntityId(id); + } + + /// + /// Drains a reserve-1 field through a temporary harvester standing + /// on it, then removes that harvester again — the field stays + /// registered but exhausted. Needs the HQ's storage room + /// ( first), or the gather is held. + /// + public void ExhaustField(ushort fieldId, int cellX, int cellY) + { + EntityId drain = Entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(cellX), SimFixed.FromInt(cellY)), + SimFixed.FromInt(2), + role: UnitRole.Harvester); + Entities.GetUnitRef(drain).HarvestFieldId = fieldId; + Step(1); + Assert.That(Economy.TryGetField(fieldId, out AetheriumField field) && field.IsExhausted, Is.True, + $"field {fieldId} must be exhausted before the test proper"); + Assert.That(Entities.DespawnUnit(drain), Is.True); + } } [Test] @@ -477,6 +514,129 @@ public void QueueUnit_ForeignFactionDefinition_IsRejectedInvalidTarget() Assert.That(f.Economy.GetPlayerEconomy(1).AetheriumCredits, Is.EqualTo(1000L)); } + // ------------------------------------------------------------------ + // #134 (D-121): a produced Harvester is born harvesting + // ------------------------------------------------------------------ + + [Test] + public void ProducedHarvester_IsBornWithAnOrderOnTheNearestFieldWithReserve() + { + // Measured from the Refinery's footprint centre (21,21), the same + // anchor and rule as the founding grant (#43): field 1 is + // registered first but lies 39 cells away, field 2 at Chebyshev 6, + // field 3 at 9. + var f = new Fixture(configure: e => + { + Assert.That(e.TryAddField(1, new GridPos2D(60, 60), 9000), Is.True); + Assert.That(e.TryAddField(2, new GridPos2D(21, 27), 9000), Is.True); + Assert.That(e.TryAddField(3, new GridPos2D(30, 21), 9000), Is.True); + }); + uint refinery = f.SpawnRefinery(0); + Assert.That(f.Production.TryQueueUnit(0, refinery, 2, 1), Is.True, "Alliance Harvester (def 2, 700 AE)"); + + f.Step(249); + Assert.That(CountRole(f, UnitRole.Harvester), Is.EqualTo(0), "one tick short of 250"); + f.Step(1); + Assert.That(CountRole(f, UnitRole.Harvester), Is.EqualTo(1), "spawned after exactly 250 full-power ticks"); + + ref readonly UnitState harvester = ref f.Entities.GetUnitRef(FindRole(f, UnitRole.Harvester)); + Assert.That(harvester.HarvestFieldId, Is.EqualTo((ushort)2), + "born with a standing harvest order on the nearest field with reserve — no Harvest command needed"); + Assert.That(harvester.IsReturningCargo, Is.False); + Assert.That(harvester.IsMoving, Is.True, "the rally order is kept: the harvester still walks out first"); + Assert.That(harvester.GoalGridPos.X, Is.EqualTo(23), "default rally two cells east of the centre cell"); + Assert.That(harvester.GoalGridPos.Y, Is.EqualTo(21)); + } + + [Test] + public void ProducedHarvester_FieldTie_ResolvesToTheLowerRegistrationIndex() + { + // Both fields sit at Chebyshev 6 from the centre cell (21,21). + // Field 7 is registered FIRST: the tie goes to the registration + // index, not to the smaller id. + var f = new Fixture(configure: e => + { + Assert.That(e.TryAddField(7, new GridPos2D(27, 21), 9000), Is.True); + Assert.That(e.TryAddField(4, new GridPos2D(21, 27), 9000), Is.True); + }); + uint refinery = f.SpawnRefinery(0); + Assert.That(f.Production.TryQueueUnit(0, refinery, 2, 1), Is.True); + + f.Step(250); + + ref readonly UnitState harvester = ref f.Entities.GetUnitRef(FindRole(f, UnitRole.Harvester)); + Assert.That(harvester.HarvestFieldId, Is.EqualTo((ushort)7), + "a distance tie resolves to the lower registration index (EconomySystem.TryFindNearestField)"); + } + + [Test] + public void ProducedHarvester_SkipsAnExhaustedField() + { + // Field 1 is the nearest (Chebyshev 4) but exhausted; field 2 + // (Chebyshev 9) still holds reserve and wins. + var f = new Fixture(configure: e => + { + Assert.That(e.TryAddField(1, new GridPos2D(21, 25), 1), Is.True); + Assert.That(e.TryAddField(2, new GridPos2D(30, 30), 9000), Is.True); + }); + uint refinery = f.SpawnRefinery(0); + f.ExhaustField(1, 21, 25); + Assert.That(f.Production.TryQueueUnit(0, refinery, 2, 1), Is.True); + + f.Step(250); + + ref readonly UnitState harvester = ref f.Entities.GetUnitRef(FindRole(f, UnitRole.Harvester)); + Assert.That(harvester.HarvestFieldId, Is.EqualTo((ushort)2), + "an exhausted field is never handed out — its order would resolve on the first tick"); + } + + [Test] + public void ProducedHarvester_WithoutAFieldWithReserve_IsBornWithoutAnOrder_AndTheQueueRuns() + { + var f = new Fixture(startingCredits: 1500, configure: e => + { + Assert.That(e.TryAddField(1, new GridPos2D(21, 25), 1), Is.True); + }); + uint refinery = f.SpawnRefinery(0); + f.ExhaustField(1, 21, 25); + Assert.That(f.Production.TryQueueUnit(0, refinery, 2, 2), Is.True, "two Harvesters, 1.400 AE"); + + f.Step(500); + + Assert.That(CountRole(f, UnitRole.Harvester), Is.EqualTo(2), + "a missing field never holds production up"); + Assert.That(f.Production.TotalQueuedUnits, Is.EqualTo(0)); + UnitState[] units = f.Entities.RawUnits; + for (int i = 0; i < f.Entities.Capacity; i++) + { + if (!units[i].IsActive || units[i].Role != UnitRole.Harvester) continue; + Assert.That(units[i].HarvestFieldId, Is.EqualTo((ushort)0), + "only exhausted fields remain: the harvester is born without an order"); + Assert.That(units[i].GoalGridPos.X, Is.EqualTo(23), "the rally order is issued all the same"); + Assert.That(units[i].GoalGridPos.Y, Is.EqualTo(21)); + } + } + + [Test] + public void ProducedInfantry_GetsNoFieldOrder_AndKeepsItsRallyOrder() + { + var f = new Fixture(configure: e => + { + Assert.That(e.TryAddField(1, new GridPos2D(11, 14), 9000), Is.True); + }); + uint barracks = f.SpawnBarracks(0); // centre (11,11) -> default rally (13,11) + Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); + + f.Step(100); + + ref readonly UnitState infantry = ref f.Entities.GetUnitRef(FindRole(f, UnitRole.BasicInfantry)); + Assert.That(infantry.HarvestFieldId, Is.EqualTo((ushort)0), + "the birth order is the Harvester's alone, even with a field three cells away"); + Assert.That(infantry.IsMoving, Is.True); + Assert.That(infantry.GoalGridPos.X, Is.EqualTo(13)); + Assert.That(infantry.GoalGridPos.Y, Is.EqualTo(11)); + } + private static int CountRole(Fixture f, UnitRole role) { int count = 0; diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs index 01cfcf50..1faac6b9 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs @@ -333,6 +333,17 @@ private static bool AnyHarvesterWorking(AiHost host, byte slot) return false; } + private static EntityId FirstHarvester(AiHost host, byte slot) + { + UnitState[] units = host.Entities.RawUnits; + for (int i = 0; i < host.Entities.Capacity; i++) + { + ref readonly UnitState u = ref units[i]; + if (u.IsActive && u.PlayerId == slot && u.Role == UnitRole.Harvester) return u.Id; + } + return EntityId.Invalid; + } + private static int MinCombatCellX(AiHost host, byte slot) { int min = int.MaxValue; @@ -442,6 +453,56 @@ public void SkirmishAi_QueuesHarvesters_AndHarvestIncomeRefillsTheTreasury() "idle harvesters must receive explicit Harvest intents on the own field"); } + /// + /// Regression for the hang behaviour revision 10 closes (#134, D-121). + /// Production and the founding grant pick a harvester's field as the + /// nearest one to the Refinery; this AI picks the nearest one to its + /// HQ. Where the two disagree, the r9 gather escort walked the + /// harvester to the AI's field while its order named the other one — + /// and the economy HOLDS an out-of-reach order instead of dropping it, + /// so the harvester stood there with frozen cargo for good. The + /// two-field map of this suite makes both rules agree, so the test + /// creates the disagreement directly: a third field away from the AI + /// base, and the AI's first harvester ordered onto it the tick it + /// appears. Artificial on purpose; the mechanism is the real one. + /// + [Test] + public void SkirmishAi_RetasksAHarvesterOrderedOntoAForeignField_AndItMines() + { + AiHost host = BuildMatch(Seed); + + EntityId harvester = EntityId.Invalid; + for (int i = 0; i < 2000 && !harvester.IsValid; i++) + { + host.Step(); + harvester = FirstHarvester(host, AiSlot); + } + Assert.That(harvester.IsValid, Is.True, "the AI's Refinery hands out its first harvester"); + + const ushort ownFieldId = 2; // slot 1's field at (117,117), see ApplyOpeningPosition + const ushort foreignFieldId = 3; // far from the AI's HQ and Refinery alike + Assert.That(host.Economy.TryAddField(foreignFieldId, new GridPos2D(95, 100), FieldReserveAE), Is.True); + ref UnitState forced = ref host.Entities.GetUnitRef(harvester); + forced.HarvestFieldId = foreignFieldId; + forced.IsReturningCargo = false; + Assert.That(forced.CargoAE, Is.EqualTo(0), "a fresh harvester carries nothing yet"); + + bool retasked = false; + bool mined = false; + for (int i = 0; i < 1000 && !mined; i++) + { + host.Step(); + ref readonly UnitState h = ref host.Entities.GetUnitRef(harvester); + if (h.HarvestFieldId == ownFieldId) retasked = true; + if (h.CargoAE > 0 || h.IsReturningCargo) mined = true; + } + + Assert.That(retasked, Is.True, + "the AI re-tasks a harvester whose order names a field other than its own (r10)"); + Assert.That(mined, Is.True, + "the re-tasked harvester mines instead of standing in reach of the wrong field with frozen cargo"); + } + // ---------------------------------------------------------------- // (c) Army: infantry production up to the threshold, then the march // ---------------------------------------------------------------- @@ -562,7 +623,12 @@ public void AiBehaviorId_TracksWhichAiThisIs() // makes it measurable one-sided (defendHomeCells, 0 = off, M001). // A revision bump with an unmoved profile hash would have meant a // rule nobody can switch off. - Assert.That(AiBehaviorId.Value, Is.EqualTo("r9.1E6E7AE3"), + // + // r10 (#134, D-121) moves the revision only: a new rule for which + // harvesters get a Harvest intent, no new number. The canonical + // outcome moved in the same PR, but from the production change + // underneath — measured on the same head with and without r10. + Assert.That(AiBehaviorId.Value, Is.EqualTo("r10.1E6E7AE3"), "the AI identifier changed — bump the revision and write the journal entry"); } diff --git a/tools/Nova.SimRunner/Determinism10000Scenario.cs b/tools/Nova.SimRunner/Determinism10000Scenario.cs index 599d04d9..f054ec7f 100644 --- a/tools/Nova.SimRunner/Determinism10000Scenario.cs +++ b/tools/Nova.SimRunner/Determinism10000Scenario.cs @@ -145,7 +145,10 @@ internal sealed class DeterminismComparison /// progression), set the completed Refinery's rally point and queue two /// Harvesters at it (production — the Refinery, not the HQ, is the /// Harvester's producer since D-077), then issue a Harvest order to - /// every produced harvester and let the harvest/return auto-cycle + /// every own harvester exactly once — a produced Harvester is born + /// harvesting since #134 (D-121), so the order re-targets the birth + /// order onto the slot's field and keeps the Harvest command kind in + /// the recorded stream — and let the harvest/return auto-cycle /// (economy) climb the credit curve for the remaining ticks. Movement /// and pathfinding run along via the builder walk. Combat orders are no /// longer part of the script: D-077 removed the midfield skirmish @@ -301,16 +304,24 @@ private sealed class SlotLayout /// /// Live handles the script queries against (captured at setup) plus - /// the two script latches. A latch flips exactly once, as a pure - /// function of the tick and the polled host state — the script stays - /// a deterministic function of the match, like the fixed tick table - /// before D-077. + /// the two script latches and the set of harvesters already ordered. + /// A latch flips exactly once and a harvester enters the set exactly + /// once, as a pure function of the tick and the polled host state — + /// the script stays a deterministic function of the match, like the + /// fixed tick table before D-077. /// private sealed class SlotState { public EntityId Builder; public bool RefineryPlaced; public bool HarvestersQueued; + + /// + /// Raw ids of the own harvesters the script has already ordered + /// (#134): since D-121 a produced harvester is born with an order, + /// so "no standing order" no longer marks the new ones. + /// + public readonly HashSet HarvestersOrdered = new HashSet(); } private sealed class Host @@ -612,9 +623,11 @@ public static DeterminismComparison Compare(DeterminismRunResult own, PlatformPr /// reach of the field AND return reach of the footprint, so the /// cycle closes without walking) and queues two Harvesters: the /// Refinery, not the HQ, is the Harvester's producer since D-077. - /// Every harvester without a standing harvest order then receives a - /// Harvest command on the slot's field, and the harvest/return - /// auto-cycle (EconomySystem) runs unaided for the remaining ticks. + /// Every own harvester then receives a Harvest command on the slot's + /// field exactly once (#134: produced harvesters are born with an + /// order since D-121, so the script tracks whom it has ordered instead + /// of reading a missing order), and the harvest/return auto-cycle + /// (EconomySystem) runs unaided for the remaining ticks. /// Definition ids are faction-resolved per slot (): /// the same script drives the Alliance rows on slot 0 and the Legion /// rows on slot 1. @@ -664,12 +677,14 @@ private static void IssueSlotCommands( } } - // Every harvester without a standing harvest order gets one — - // covers each produced harvester exactly once. - uint[] idle = IdleHarvesterRaws(host, slot); - if (idle.Length > 0) + // Every own harvester gets one Harvest order, exactly once — a + // produced harvester is born with an order since #134 (D-121), so + // the script remembers whom it ordered; the command kind stays in + // the recorded stream. + uint[] unordered = UnorderedHarvesterRaws(host, slot, state); + if (unordered.Length > 0) { - Submit(host, slot, new HarvestPayload(idle, c.FieldId), ref aiSequence); + Submit(host, slot, new HarvestPayload(unordered, c.FieldId), ref aiSequence); } } @@ -958,17 +973,24 @@ private static uint FindRoleRaw(Host host, byte slot, UnitRole role) return 0; } - /// Raw ids of all active own harvesters without a standing harvest order, ascending index. - private static uint[] IdleHarvesterRaws(Host host, byte slot) + /// + /// Raw ids of all active own harvesters the script has not ordered + /// yet, ascending index; each returned id is recorded in + /// , so it is returned exactly once (#134). + /// + private static uint[] UnorderedHarvesterRaws(Host host, byte slot, SlotState state) { var raws = new List(); UnitState[] units = host.Entities.RawUnits; for (int i = 0; i < host.Entities.Capacity; i++) { - if (units[i].IsActive && units[i].PlayerId == slot - && units[i].Role == UnitRole.Harvester && units[i].HarvestFieldId == 0) + if (units[i].IsActive && units[i].PlayerId == slot && units[i].Role == UnitRole.Harvester) { - raws.Add(UnitCommandStateView.ToRawEntityId(units[i].Id)); + uint raw = UnitCommandStateView.ToRawEntityId(units[i].Id); + if (state.HarvestersOrdered.Add(raw)) + { + raws.Add(raw); + } } } return raws.ToArray(); From 67986189c99cc765150be30b2d6f213638c755d4 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 18:05:36 +0200 Subject: [PATCH 2/2] docs(report): record the Unity lane results for this package EditMode 671/671 and PlayMode 15/15 measured on this branch with Unity 6000.5.4f1 via the lane script from #110; no failures, clean tree. Co-Authored-By: Claude Fable 5.1 --- .../02-issue-134-harvester-auto-harvest.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/reports/v8.6.0/issue-lauf-2026-10-17/02-issue-134-harvester-auto-harvest.md b/reports/v8.6.0/issue-lauf-2026-10-17/02-issue-134-harvester-auto-harvest.md index a4e83497..6a15d94b 100644 --- a/reports/v8.6.0/issue-lauf-2026-10-17/02-issue-134-harvester-auto-harvest.md +++ b/reports/v8.6.0/issue-lauf-2026-10-17/02-issue-134-harvester-auto-harvest.md @@ -154,3 +154,7 @@ Vier bewertete Alternativen: Sim-Weg (gewählt), der Client-Weg aus `73f54b5`, g > neuen Testbuild ausgeben. > > 🤖 Generated with [Claude Code](https://claude.com/claude-code) + +## Unity-Spuren nachgefahren (Integrator, 2026-10-17) + +Nach dem Commit mit `tools/unity-tests/run_unity_tests.py` (Branch #110) auf diesem Worktree, Unity 6000.5.4f1, warme Library: **EditMode 671/671, PlayMode 15/15**, keine Fehlschläge, keine Compile-Fehler, Arbeitsbaum danach sauber. Die Zeile „Unity-Spuren: nicht gelaufen“ oben ist damit überholt; der PR-Text nennt die gemessenen Zahlen.