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();