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; + } + } +}