Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 88 additions & 15 deletions Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <see cref="AiFactionProfile.TargetHarvesterCount"/>, 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;
Expand Down Expand Up @@ -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);
Expand All @@ -392,7 +411,7 @@ private void Decide()
}
}

if (idleHarvesterRaws.Count > 0)
if (haveField && idleHarvesterRaws.Count > 0)
{
idleHarvesterRaws.Sort();
SubmitEntityList(idleHarvesterRaws,
Expand All @@ -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,
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
/// <para>
/// WITHOUT <paramref name="mustHaveReserve"/> THE ECONOMY LIVELOCKS, and
/// a beta test found it (issue #85): <see cref="EconomySystem"/> clears
/// <c>HarvestFieldId</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// THIS IS NOT NEW INFORMATION FOR AN AI TO READ. The human path has
/// filtered exhausted fields all along (<c>RtsDeviceInput</c>); 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.
/// </para>
/// <para>
/// Determinism unchanged: ascending ids, <c>long</c> distances, strict
/// <c>&lt;</c> so a tie keeps the LOWER id. No float, no hash container,
/// no dependency on iteration order.
/// </para>
/// </summary>
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;
Expand All @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading