diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
index 9935045..54d9a36 100644
--- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
+++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
@@ -60,8 +60,11 @@ namespace Nova.AI
/// 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 own field, and harvesters
- /// held out of reach are WALKED into the economy's reach rule with
+ /// harvester 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
+ /// 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
/// use the Refinery's rally point at all, it micro-manages like a human;
@@ -379,10 +382,26 @@ private void Decide()
// instead of a hardcoded list, precisely so the producer move
// could not strand it. Using the rally point here would change
// behavior and belongs in its own PR. ----
- if (refineryRaw != 0
- && TryGetOwnFieldCell(hqCellX, hqCellY, out ushort ownFieldId, out int fieldX, out int fieldY))
+ if (refineryRaw != 0)
{
- if (SimDefinitions.TryGetUnit(faction, UnitRole.Harvester, out SimUnitDefinition harvesterDef))
+ // A field that can still be mined — see TryGetOwnFieldCell for
+ // what happens without the reserve test (issue #85).
+ bool haveField = TryGetOwnFieldCell(
+ hqCellX, hqCellY, mustHaveReserve: true,
+ out ushort ownFieldId, out int fieldX, out int fieldY);
+
+ // NOTHING LEFT TO MINE IS NOT THE SAME AS NOTHING LEFT TO DO,
+ // and the difference is the whole reason this gate sits inside
+ // the step instead of on it. Everything that needs a field —
+ // ordering more harvesters, sending idle ones out, walking them
+ // to the gather spot — stops. The RETURN leg does not: a
+ // harvester holding its last load has somewhere to take it, and
+ // an out-of-reach return order is HELD rather than dropped, so
+ // closing that distance stays the AI's job. Skipping the whole
+ // step would have stranded the final loads at the moment the
+ // map runs dry — a smaller defect than #85, and a new one.
+ if (haveField
+ && SimDefinitions.TryGetUnit(faction, UnitRole.Harvester, out SimUnitDefinition harvesterDef))
{
int have = harvesters + CountQueuedAt(refineryRaw, harvesterDef.DefinitionId);
int batch = Math.Min(HarvesterQueueBatch, _profile.TargetHarvesterCount - have);
@@ -392,7 +411,7 @@ private void Decide()
}
}
- if (idleHarvesterRaws.Count > 0)
+ if (haveField && idleHarvesterRaws.Count > 0)
{
idleHarvesterRaws.Sort();
SubmitEntityList(idleHarvesterRaws,
@@ -407,13 +426,16 @@ private void Decide()
// footprint. Deterministic ascending picks.
int refineryOriginX = refineryCellX - 1;
int refineryOriginY = refineryCellY - 1;
- bool haveGatherSpot = TryFindDualReachCell(fieldX, fieldY, refineryOriginX, refineryOriginY,
- out int gatherX, out int gatherY);
- if (!haveGatherSpot)
+ int gatherX = 0, gatherY = 0;
+ if (haveField)
{
- // The field cell itself always satisfies harvest reach.
- gatherX = fieldX;
- gatherY = fieldY;
+ if (!TryFindDualReachCell(fieldX, fieldY, refineryOriginX, refineryOriginY,
+ out gatherX, out gatherY))
+ {
+ // The field cell itself always satisfies harvest reach.
+ gatherX = fieldX;
+ gatherY = fieldY;
+ }
}
int returnX, returnY;
bool haveReturnSpot = TryFindFootprintAdjacentCell(refineryOriginX, refineryOriginY,
@@ -441,7 +463,11 @@ private void Decide()
{
// Out-of-reach harvest orders are HELD, never dropped
// (EconomySystem) — closing the distance is the AI's
- // job, exactly like a human's move click.
+ // job, exactly like a human's move click. With no
+ // mineable field there is nothing to close a distance
+ // to, and walking them to the empty one is the loop
+ // this whole change exists to end.
+ if (!haveField) continue;
if (IsInFieldReach(cellX, cellY, fieldX, fieldY)) continue;
if (AlreadyHeadingTo(in harvester, gatherX, gatherY)) continue;
gatherEscort.Add(harvesterRaws[i]);
@@ -1158,8 +1184,13 @@ private void TryPlaceBuilding(FactionId faction, UnitRole role, long credits, in
{
if (!SimDefinitions.TryGetBuilding(faction, role, out SimBuildingDefinition def)) return;
if (credits < def.CostAE) return;
+ // The anchor answers "where is my base", not "where can I mine", so
+ // an exhausted field is still the right answer here — see
+ // TryGetOwnFieldCell. Filtering here as well would put a rebuilt
+ // Refinery next to whatever field still has reserve, which on the
+ // canonical map is across the board.
int anchorX = hqCellX, anchorY = hqCellY;
- if (TryGetOwnFieldCell(hqCellX, hqCellY, out _, out int fieldX, out int fieldY))
+ if (TryGetOwnFieldCell(hqCellX, hqCellY, mustHaveReserve: false, out _, out int fieldX, out int fieldY))
{
anchorX = fieldX;
anchorY = fieldY;
@@ -1264,8 +1295,49 @@ private bool TryFindFootprintAdjacentCell(int originX, int originY, out int cell
/// id) — the demo map seats every base beside its field. Field ids are
/// host-assigned and nonzero; the registry is probed over its format
/// capacity in ascending id order.
+ ///
+ /// WITHOUT THE ECONOMY LIVELOCKS, and
+ /// a beta test found it (issue #85): clears
+ /// HarvestFieldId the moment a field is empty, that clearing is
+ /// exactly what puts the harvester back into the idle list of the
+ /// economy step, and the idle list is sent straight back to the same
+ /// empty field. Every decision tick, for the rest of the match, at an
+ /// income of zero. The AI was not slow after its field ran out, it was
+ /// economically dead — while the three fields away from the two start
+ /// positions (9.000, 9.000 and 15.000 AE) stood open.
+ ///
+ ///
+ /// THE FLAG IS THE DIFFERENCE BETWEEN THE TWO CALLERS, and it is not a
+ /// detail. The economy step needs a field it can still mine. The
+ /// placement step needs to know WHERE THE OWN BASE IS and uses the
+ /// nearest field as that anchor — skipping exhausted fields there too
+ /// would move a rebuilt Refinery to whatever field still has reserve, on
+ /// the canonical map the contested centre or the far corner, sixty cells
+ /// from home. Where a Refinery belongs once the home field runs dry is a
+ /// real question and a strategic one; answering it here by accident
+ /// would be worse than not answering it.
+ ///
+ ///
+ /// THIS IS NOT NEW INFORMATION FOR AN AI TO READ. The human path has
+ /// filtered exhausted fields all along (RtsDeviceInput); the two
+ /// were meant to share the rule from the moment the fields became
+ /// finite, and only one of them was carried over. A field's remaining
+ /// reserve is committed state and no more fog-hidden than its position,
+ /// so the READ BOUNDARY in the class remarks is untouched.
+ ///
+ ///
+ /// Determinism unchanged: ascending ids, long distances, strict
+ /// < so a tie keeps the LOWER id. No float, no hash container,
+ /// no dependency on iteration order.
+ ///
///
- private bool TryGetOwnFieldCell(int hqCellX, int hqCellY, out ushort fieldId, out int cellX, out int cellY)
+ private bool TryGetOwnFieldCell(
+ int hqCellX,
+ int hqCellY,
+ bool mustHaveReserve,
+ out ushort fieldId,
+ out int cellX,
+ out int cellY)
{
fieldId = 0;
cellX = 0;
@@ -1274,6 +1346,7 @@ private bool TryGetOwnFieldCell(int hqCellX, int hqCellY, out ushort fieldId, ou
for (ushort id = 1; id <= EconomySystem.MaxFields; id++)
{
if (!_economy.TryGetField(id, out AetheriumField field)) continue;
+ if (mustHaveReserve && field.IsExhausted) continue;
long dx = field.GridPos.X - hqCellX;
long dy = field.GridPos.Y - hqCellY;
long distanceSquared = dx * dx + dy * dy;
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cdd6d71..de459bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -73,6 +73,32 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt
### Behoben
+- **#85: Die KI erntet nicht länger endlos auf dem leeren Feld.** Aus dem
+ Betatest vom 10.08.2026: die KI kam nach Erschöpfung ihres Startvorkommens
+ wirtschaftlich zum Stillstand. Das war kein Strategiemangel, sondern ein
+ **Livelock aus einer fehlenden Prüfung** — `TryGetOwnFieldCell` wählte das
+ Erntefeld allein nach Distanz zum HQ und sah `IsExhausted` nicht an. Der
+ `EconomySystem` räumt beim leeren Feld `HarvestFieldId`, genau dieses Räumen
+ liess den Harvester in die Leerlaufliste der KI fallen, und die schickte ihn
+ auf dasselbe leere Feld zurück: jeden Entscheidungstick, bei Einkommen null,
+ während drei registrierte Felder mit zusammen 33.000 AE offenstanden. Die
+ Erntewahl überspringt erschöpfte Felder jetzt; ist keines mehr übrig, ruhen
+ Nachbestellung und Erntebefehle, **statt Kommandos ins Leere zu schicken**.
+ Der Spielerpfad filterte seit den endlichen Feldern (#80) bereits korrekt —
+ nachgezogen wurde nur eine der beiden Stellen. **Der Platzierungsanker filtert
+ bewusst weiterhin nicht:** er beantwortet „wo ist meine Basis", und ein
+ nachgebautes Refinery ans nächste Feld *mit* Reserve zu setzen hiesse auf
+ dieser Karte quer über das Feld — das ist eine strategische Entscheidung und
+ gehört nicht als Nebenwirkung in einen Livelock-Fix. **Die kanonische Partie
+ bleibt byte-identisch** (Entscheidung Tick 3.213, Endzustand
+ `0xE002DD893916967B`): dort erschöpft sich kein Feld, die Regel greift also
+ nicht — deshalb bleiben auch die vier Determinismus-Baselines grün.
+ **Im laufenden Spiel gesehen:** eine Partie auf diesem Stand gespielt — die
+ Harvester fahren nach dem Startvorkommen zu anderen Quellen, und es kommen
+ weiter neue Einheiten, bis alles zerstört ist. Der Bezeichner bleibt
+ `r7.E34435F9`: die kanonische Partie entscheidet unverändert, und `r8` ist
+ bereits an das Basisverteidigungs-Verhalten vergeben — zwei verschiedene
+ Stände dürfen sich keine Kennung teilen
- **#45/#47/#48: Entscheidungspunkt und „Stoppen“ melden jetzt die Wahrheit (D-097):** Die Baubar zeigt dauerhaft die Strombilanz samt Low-Power-Folge, nennt beim Überfahren Bedarf beziehungsweise Erzeugung und leitet den ersten Blocker in der ausdrücklich festgelegten HUD-Priorität Voraussetzung, AE, freie Energie und Baustellenlimit her; diese Priorität ist nicht die globale Executor-Reihenfolge. Energie sperrt den Eintritt in den Platzierungsmodus bewusst nicht. Die Befehlskarte zeigt den Stromwert des gewählten Gebäudes, und ein angewandter Stop-Befehl räumt zusätzlich `AttackTarget` ab. Ein echtes Halte-Feuer bleibt ausserhalb dieses Pakets, weil D-087 im nächsten Combat-Tick wieder ein Ziel erfassen darf
- **Low Power ist eine Waffe (C4, Sprint 16.6)** — bei Energiedefizit
fällt Radar zuerst: `FogOfWarSystem.GetRadarSignatures` liefert nichts mehr
diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiFieldExhaustionTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiFieldExhaustionTests.cs
new file mode 100644
index 0000000..31d18b7
--- /dev/null
+++ b/tools/Nova.SimRunner.Tests/SkirmishAiFieldExhaustionTests.cs
@@ -0,0 +1,173 @@
+using NUnit.Framework;
+using Nova.Core;
+using Nova.Simulation.Economy;
+using Nova.Simulation.Pathfinding;
+using Nova.Simulation.State;
+
+namespace Nova.SimRunner.Tests
+{
+ ///
+ /// What the AI does when the field it is mining runs out (issue #85, found
+ /// in the beta test of 2026-08-10).
+ ///
+ /// THE DEFECT WAS A LIVELOCK, not a missing strategy. The economy clears
+ /// HarvestFieldId the moment a field is empty; that clearing is
+ /// exactly what puts the harvester back into the AI's idle list; and the
+ /// idle list was sent straight back to the same empty field. Income zero,
+ /// every decision tick, for the rest of the match — with other registered
+ /// fields standing open.
+ ///
+ ///
+ /// THE SETUP MINES THE FIELD OUT INSTEAD OF DECLARING IT EMPTY, because a
+ /// field cannot be registered as exhausted (TryAddField refuses a
+ /// reserve of 0). That is not a workaround, it is the better test: it walks
+ /// the exact sequence the beta test walked — mine, run dry, and then either
+ /// carry on somewhere else or spin.
+ ///
+ ///
+ [TestFixture]
+ public sealed class SkirmishAiFieldExhaustionTests
+ {
+ private const byte AiSlot = 1;
+
+ /// The field the opening gives the AI: (117,117), effectively endless.
+ private const ushort HomeFieldId = 2;
+
+ /// A small field placed NEARER the AI's HQ than its home field, so the AI picks it first.
+ private const ushort NearFieldId = 3;
+
+ ///
+ /// Enough to be picked, worked and delivered from — and little enough to
+ /// run out inside the budget. At HarvestRateAE per tick and two
+ /// harvesters this is a few dozen ticks of actual mining.
+ ///
+ private const long NearFieldReserveAE = 200L;
+
+ private const int BudgetTicks = 4000;
+
+ [Test]
+ public void WhenTheNearFieldRunsOut_TheAiMinesAnotherRegisteredField()
+ {
+ SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed);
+
+ Assert.That(TryHqCell(host, AiSlot, out int hqX, out int hqY), Is.True, "the AI has no HQ to sit at");
+ Assert.That(host.Economy.TryAddField(NearFieldId, new GridPos2D(hqX + 1, hqY + 1), NearFieldReserveAE),
+ Is.True, "the small near field could not be registered");
+
+ // It has to be the NEAREST, or the AI never picks it and the test
+ // measures nothing at all.
+ Assert.That(host.Economy.TryGetField(HomeFieldId, out AetheriumField home), Is.True);
+ Assert.That(DistanceSquared(hqX, hqY, hqX + 1, hqY + 1),
+ Is.LessThan(DistanceSquared(hqX, hqY, home.GridPos.X, home.GridPos.Y)),
+ "the small field is not the nearest one, so the AI would never have chosen it");
+
+ bool minedTheNearField = false;
+ bool exhausted = false;
+ bool minedElsewhereAfterwards = false;
+ long creditsAtExhaustion = 0;
+
+ for (int tick = 0; tick < BudgetTicks && !host.Victory.IsDecided; tick++)
+ {
+ host.Step();
+
+ Assert.That(host.Economy.TryGetField(NearFieldId, out AetheriumField near), Is.True);
+ if (!exhausted)
+ {
+ if (AnyHarvesterGatheringAt(host, AiSlot, NearFieldId)) minedTheNearField = true;
+ if (near.IsExhausted)
+ {
+ exhausted = true;
+ creditsAtExhaustion = host.Economy.GetPlayerEconomy(AiSlot).AetheriumCredits;
+ }
+ continue;
+ }
+
+ // AFTER exhaustion the empty field must never be handed out
+ // again. This is the assertion the defect fails on: before the
+ // fix every single decision tick re-issued exactly this.
+ Assert.That(AnyHarvesterGatheringAt(host, AiSlot, NearFieldId), Is.False,
+ $"tick {host.Kernel.CurrentTick.Value}: a harvester was sent back to the exhausted field");
+
+ if (AnyHarvesterGatheringAt(host, AiSlot, HomeFieldId)) minedElsewhereAfterwards = true;
+ }
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(minedTheNearField, Is.True,
+ "the AI never mined the near field, so nothing was exhausted and nothing is under test");
+ Assert.That(exhausted, Is.True,
+ $"the near field still holds reserve after {BudgetTicks} ticks — raise the budget or lower it");
+ Assert.That(minedElsewhereAfterwards, Is.True,
+ "the AI stopped mining altogether once its near field ran out — that is the defect of #85");
+ Assert.That(host.Economy.GetPlayerEconomy(AiSlot).AetheriumCredits,
+ Is.GreaterThan(creditsAtExhaustion),
+ "no income arrived after the near field ran out, so the economy did not actually resume");
+ });
+ }
+
+ // NO TEST FOR THE PLACEMENT ANCHOR, and that is deliberate rather than
+ // an omission. The anchor keeps looking at the nearest field whether it
+ // is exhausted or not, because it answers "where is my base" — but on
+ // the canonical opening the nearest field WITH reserve sits two cells
+ // from the nearest field without one, so filtering the anchor would
+ // move nothing and a test could not tell the two rules apart. It would
+ // pass for the shape of the map, not for the rule, and advertise a
+ // guarantee it does not hold. The reasoning lives where it belongs, in
+ // the remarks on TryGetOwnFieldCell.
+
+ // ----------------------------------------------------------------
+
+ private static long DistanceSquared(int ax, int ay, int bx, int by)
+ {
+ long dx = ax - bx, dy = ay - by;
+ return dx * dx + dy * dy;
+ }
+
+ ///
+ /// A harvester of that is GATHERING at
+ /// — deliberately not one that is on its way
+ /// home.
+ ///
+ /// THE DISTINCTION IS LOAD-BEARING and it cost this test a false
+ /// failure waiting to happen. A harvester that filled up keeps the field
+ /// id while it delivers, and the economy keeps it ON PURPOSE even when
+ /// the field is empty, so the last load is not stranded
+ /// (EconomySystem). Counting those would mean the assertion
+ /// "nobody was sent back to the empty field" fires at a harvester nobody
+ /// sent anywhere — on some seeds, and not on this one, which is the
+ /// worst kind of test. What the AI can be held to is who it assigns, and
+ /// it only ever assigns idle gatherers.
+ ///
+ ///
+ private static bool AnyHarvesterGatheringAt(SkirmishAiTests.AiHost host, byte slot, ushort fieldId)
+ {
+ UnitState[] units = host.Entities.RawUnits;
+ for (int i = 0; i < units.Length; i++)
+ {
+ ref readonly UnitState u = ref units[i];
+ if (!u.IsActive || u.PlayerId != slot) continue;
+ if (u.Role != UnitRole.Harvester) continue;
+ if (u.IsReturningCargo) continue;
+ if (u.HarvestFieldId == fieldId) return true;
+ }
+ return false;
+ }
+
+ private static bool TryHqCell(SkirmishAiTests.AiHost host, byte slot, out int cellX, out int cellY)
+ {
+ UnitState[] units = host.Entities.RawUnits;
+ for (int i = 0; i < units.Length; i++)
+ {
+ ref readonly UnitState u = ref units[i];
+ if (!u.IsActive || u.PlayerId != slot || u.Role != UnitRole.HQ) continue;
+ if (host.Construction.IsActiveSite(u.Id)) continue;
+ cellX = SimFixed.WorldToGrid(u.Transform.PositionX);
+ cellY = SimFixed.WorldToGrid(u.Transform.PositionY);
+ return true;
+ }
+ cellX = -1;
+ cellY = -1;
+ return false;
+ }
+ }
+}