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..6a15d94b --- /dev/null +++ b/reports/v8.6.0/issue-lauf-2026-10-17/02-issue-134-harvester-auto-harvest.md @@ -0,0 +1,160 @@ +--- +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) + +## 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. 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();