From 574c06ddd3e782d856952ca7729f1b1cc5333a15 Mon Sep 17 00:00:00 2001 From: Aaron Hieronymus Date: Mon, 10 Aug 2026 21:57:35 +0200 Subject: [PATCH] fix(ai): nicht laenger endlos auf dem leeren Aetheriumfeld ernten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aus dem Betatest vom 10.08.2026 (Issue #85): die KI kam nach Erschoepfung ihres Startvorkommens wirtschaftlich zum Stillstand. Das war kein Strategiemangel, sondern ein LIVELOCK aus einer fehlenden Pruefung. TryGetOwnFieldCell waehlte das Erntefeld allein nach Distanz zum HQ und sah IsExhausted nicht an. Der EconomySystem raeumt beim leeren Feld HarvestFieldId, genau dieses Raeumen liess den Harvester in die Leerlaufliste der KI fallen, und die schickte ihn auf dasselbe leere Feld zurueck — jeden Entscheidungstick, bei Einkommen null, waehrend die drei Felder abseits der Startpositionen mit 33.000 AE offenstanden. Der Spielerpfad filtert erschoepfte Felder seit den endlichen Feldern (#80) korrekt; nachgezogen wurde damals nur eine der beiden Stellen. ZWEI ENTSCHEIDUNGEN ueber das geforderte Minimum hinaus, beide mit Grund: - Der PLATZIERUNGSANKER filtert bewusst weiter nicht. TryGetOwnFieldCell hat eine zweite Aufrufstelle, die das naechste Feld als Anker fuer "wo ist meine Basis" benutzt. Filtert man dort mit, setzt ein nachgebautes Refinery sich ans naechste Feld MIT Reserve — auf dieser Karte das umkaempfte Zentrum oder die Gegenecke. Das ist eine strategische Entscheidung, und sie als Nebenwirkung zu treffen waere schlechter, als sie offen zu lassen. - Der RUECKWEG bleibt bedient. Den ganzen Wirtschaftsschritt zu ueberspringen, wenn kein Feld mehr uebrig ist, haette einen kleineren neuen Defekt erzeugt: ein Harvester mit letzter Ladung ausserhalb der Abladereichweite bleibt dann stehen, weil ein gehaltener Rueckwegbefehl nur durch die KI aufgeloest wird. Gegattert sind deshalb nur die feldabhaengigen Teile. BELEGE - Neuer Test SkirmishAiFieldExhaustionTests: ein kleines Feld naeher am HQ wird leergemint, danach darf es nie wieder zugewiesen werden und die KI muss anderswo weiterernten. Gegenprobe gefahren — mit abgeschalteter Pruefung faellt er mit genau diesem Befund. - 708/708 gruen, keine der vier Baseline-Dateien angefasst. - Die kanonische Partie ist BYTE-IDENTISCH (Tick 3.213, Endzustand 0xE002DD893916967B, alle Laborartefakte gleich): dort erschoepft sich kein Feld, die neue Bedingung greift also nie. Anders als im Issue vermutet wird damit keine Determinismus-Baseline rot, und es braucht keinen zweiten PR. - 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 zerstoert ist. Ein Nebenbefund fuer die Werkzeugkiste: der Executor nimmt einen Erntebefehl auf ein leeres Feld an, die Economy raeumt ihn danach. Der Defekt erzeugte also NULL abgelehnte Intents — die Spalte intentsRejected, die im Labor als Fruehwarnsignal gilt, kann diese Fehlerklasse grundsaetzlich nicht sehen. AiBehaviorId.Revision bleibt bei 7: die kanonische Partie entscheidet unveraendert, und 8 ist bereits an das Basisverteidigungs-Verhalten vergeben — zwei verschiedene Staende duerfen sich keine Kennung teilen. --- .../_Project/Scripts/AI/SkirmishAiSystem.cs | 103 +++++++++-- CHANGELOG.md | 26 +++ .../SkirmishAiFieldExhaustionTests.cs | 173 ++++++++++++++++++ 3 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 tools/Nova.SimRunner.Tests/SkirmishAiFieldExhaustionTests.cs 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; + } + } +}