From fd42f2409f426cc572a03e8257f8190b47b6b898 Mon Sep 17 00:00:00 2001 From: Aaron Hieronymus Date: Mon, 10 Aug 2026 19:28:33 +0200 Subject: [PATCH 1/5] refactor(ai): benennen, was eine Einheit vorhat (GoalKind) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Armeeschritt entschied in einer if-Kette, in der keine Verzweigung einen Namen hatte. Er waehlt jetzt je Einheit und Kadenz EIN Goal aus einer festen Prioritaetsliste — Retreat, Attack, Hold, Advance — und wendet dessen Wirkung aus einer Tabelle an. ES IST KEINE VERHALTENSAENDERUNG, und das ist der ganze Zweck des Schritts: dieselben vier Bedingungen in derselben Reihenfolge, dieselben Befehle. Der Nachweis ist kein Test, sondern eine Zahl — die kanonische Partie entscheidet auf demselben Tick mit demselben Endzustand (CanonicalAiOutcomeTests), und ein Laborlauf ist byte-identisch bis auf die gemessene Laufzeit. Deshalb kein AiBehaviorId.Revision-Bump. KEIN PROFILFELD, obwohl der Plan Prioritaeten im Profil vorsah. Ein angehaengtes Feld bewegt AiProfile.ProfileHash und damit den Verhaltensbezeichner, der in jedes Laborartefakt gedruckt wird — der Schritt waere dann nicht byte-identisch gewesen, und die Byte-Gleichheit ist genau das, was ihn auswertbar macht. Eine Aus-Stellung bringt jedes Modul mit, das eine REGEL bekommt, im PR, der ihm die Regel gibt. Ein Goal ist KEIN Zustand: es wird je Kadenz neu abgeleitet und nirgends gespeichert. Die KI bleibt eine reine Funktion des committeten Zustands, es entsteht kein Sidecar-Block, kein neues System, keine Aenderung der Tick-Reihenfolge. Zwei Klauseln, die beim Modularisieren leicht verlorengehen und beide tragend sind: - Retreat tritt fuer eine Einheit zurueck, die ANGEKOMMEN ist. Heimkommen beendet den Rueckzug; MS-1-Einheiten heilen nie, eine Regel, die sie bis zur Genesung festhaelt, staut die Verwundeten zuhause und fuellt nie wieder eine Welle. - Attack verlangt, nicht auf Rueckzug zu sein. Eine verwundete Einheit ausserhalb des Rings erfuellt sonst jede andere Haelfte des Marschtests, und der Rueckzug erreichte genau die Einheit nicht, fuer die es ihn gibt. 707/707 im Spiel. --- .../_Project/Scripts/AI.Data/AiBehaviorId.cs | 11 + Assets/_Project/Scripts/AI.Data/GoalKind.cs | 71 +++++ .../_Project/Scripts/AI.Data/GoalKind.cs.meta | 2 + .../_Project/Scripts/AI/SkirmishAiSystem.cs | 250 +++++++++++------- 4 files changed, 242 insertions(+), 92 deletions(-) create mode 100644 Assets/_Project/Scripts/AI.Data/GoalKind.cs create mode 100644 Assets/_Project/Scripts/AI.Data/GoalKind.cs.meta diff --git a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs index e27904e..121c130 100644 --- a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs +++ b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs @@ -63,6 +63,17 @@ public static class AiBehaviorId /// behaviour journal V003. /// /// + /// NOT bumped either for naming those branches . + /// Same four conditions, same order, same orders out; the canonical + /// match decides on the same tick with the same end state and a lab + /// run's artifacts came out byte-identical bar the measured runtime. + /// The refactor deliberately adds NO profile field — priorities in the + /// profile would move , and with it this very + /// identifier, which would have made the neutrality unprovable in the + /// artifacts it is printed into. A goal module that earns an off switch + /// brings one in the pull request that gives it a rule. + /// + /// /// r5 fixes two defects found in the review of r3/r4, both of which /// change decisions and therefore the end state: the wave now waits for /// what production can still deliver instead of a fixed cap (a single diff --git a/Assets/_Project/Scripts/AI.Data/GoalKind.cs b/Assets/_Project/Scripts/AI.Data/GoalKind.cs new file mode 100644 index 0000000..2d86482 --- /dev/null +++ b/Assets/_Project/Scripts/AI.Data/GoalKind.cs @@ -0,0 +1,71 @@ +namespace Nova.AI.Data +{ + /// + /// What one unit is trying to do in THIS decision — a name for a condition + /// and its effect, and nothing else. + /// + /// A GOAL IS NOT STATE. It is worked out fresh on every decision cadence + /// from the committed world and thrown away again; nothing stores it, and + /// therefore nothing has to serialize it. That is what keeps the skirmish AI + /// a pure function of the tick and the committed state after the goals were + /// named — the names describe the decision, they do not survive it. + /// + /// + /// It lives in this assembly rather than beside the rules because THREE + /// SURFACES HAVE TO AGREE ON THE WORDS: the simulation that picks a goal, + /// the lab that records which one was picked, and the panel that draws it. + /// Nova.AI.Data references Nova.Core and nothing else, so every one of them + /// can name a goal without pulling the simulation in behind it. + /// + /// + /// THE ORDER OF THE VALUES IS THE PRIORITY, highest first: when more than + /// one condition holds, the lower number wins. It is a fixed order and not a + /// profile value on purpose — priorities in the profile would move + /// AiProfile.ProfileHash and with it the behaviour identifier printed + /// into every lab artifact, and the first step of this strand has to be + /// provably behaviour-neutral (ROADMAP.md point 2). A module that needs an + /// off setting brings one with it, in the pull request that gives it a rule + /// worth switching off. + /// + /// + public enum GoalKind : byte + { + /// + /// No goal — the unit was not judged at all this decision, and for the + /// goal mask () it means "leave this one to + /// the AI". Never the answer of the resolver: every combat unit the army + /// step looks at gets one of the four below. + /// + None = 0, + + /// + /// Wounded, in danger, and pulling out: walk to the staging cell and + /// shoot at whoever is chasing. Outranks everything, because a rule that + /// cannot beat "you are out with the wave, keep going" can never pull + /// anybody back. + /// + Retreat = 1, + + /// + /// Marching on the army's target: the shared attack target and the + /// shared destination. The wave is out, or this unit already is. + /// + Attack = 2, + + /// + /// Standing at the staging cell with nothing to say. THE EFFECT IS + /// SILENCE, and that is the goal's whole content: a unit that is where + /// it belongs and gets told so again every cadence turns 23 actions per + /// minute into 40 without changing anything (behaviour journal V002). + /// + Hold = 3, + + /// + /// Reinforcement on its way to the staging cell. No attack target while + /// it walks — an explicit order is released only by its target's death, + /// so aiming while not closing the distance silences the unit instead of + /// arming it (finding F001, journal V003). + /// + Advance = 4, + } +} diff --git a/Assets/_Project/Scripts/AI.Data/GoalKind.cs.meta b/Assets/_Project/Scripts/AI.Data/GoalKind.cs.meta new file mode 100644 index 0000000..97a7aac --- /dev/null +++ b/Assets/_Project/Scripts/AI.Data/GoalKind.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5ff6e2a7b6a5198b0f46fcfdabbe1a71 diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs index 9935045..bae92ec 100644 --- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs +++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs @@ -70,8 +70,10 @@ namespace Nova.AI /// Barracks stands, infantry is queued up to /// as funds allow; /// (5) the army resolves a POSTURE (does it act at all, which target, - /// which destination), then ONE ASSIGNMENT PER UNIT, then submission that - /// groups units sharing an order into a single intent: at + /// which destination), then ONE GOAL PER UNIT out of the fixed priority + /// list in Retreat, Attack, + /// Hold, Advance — whose effect is the unit's orders, then + /// submission that groups units sharing an order into a single intent: at /// living combat units /// the army is sent toward the enemy start area, and the best scored /// visible enemy receives an EXPLICIT AttackTarget intent from every unit. @@ -473,8 +475,8 @@ private void Decide() } } - // ---- (6) Army: resolve one posture for the army, one assignment - // per unit, then submit the assignments grouped. See the three + // ---- (6) Army: resolve one posture for the army, one GOAL per + // unit, then submit the resulting orders grouped. See the three // steps below; the rules are exactly the ones the previous // whole-army block applied. ---- ArmyPosture posture = ResolveArmyPosture( @@ -959,109 +961,173 @@ private bool IsCommittedToTheWave(in UnitState unit, int hqCellX, int hqCellY) } /// - /// One unit's orders under the given posture. Today every combat unit - /// gets the same two — that IS the current behaviour, and this is the - /// one place a later rule (retreat below a health threshold, waiting - /// at a staging cell) has to change to break the uniformity. + /// One unit's orders under the given posture: pick the goal, apply it. /// - /// Aiming BELOW the squad threshold was built here and measured back - /// out again — behaviour journal V003 carries the four variants and - /// the reason: an explicit order cannot be handed back to the D-087 - /// auto-acquisition, because AttackTarget is released only by - /// the target's death (UnitState.Stop() leaves it untouched). - /// A standing unit that stops closing the distance therefore holds a - /// stale order, and holding beats aiming only while the unit walks - /// toward what it aims at. + /// THE TWO HALVES ARE SEPARATE ON PURPOSE. Picking is a chain of + /// conditions; applying is a table of effects. Kept together they read + /// as one if-cascade in which no branch has a name — and a rule without + /// a name can neither be switched off, nor tested on its own, nor drawn + /// in a panel. Split, the goal is a value: it can be recorded, forced + /// from outside, and told apart from the order it produced. + /// + /// + /// NOTHING ABOUT THE DECISION CHANGED when the names went in. The + /// conditions below are the same four the if-cascade tested, in the + /// order it tested them, and the canonical match runs tick for tick as + /// it did — which is the only acceptable proof for a refactor of a rule + /// engine, and the reason this step ships without a revision bump. /// /// private UnitAssignment ResolveUnitAssignment( uint entityRaw, in UnitState unit, in ArmyPosture posture, int hqCellX, int hqCellY, List threatCells, List threatRaws) { - // A wounded unit walks home, whatever the wave is doing. This test - // comes FIRST on purpose: retreat has to outrank "you are out with - // the wave, keep going", or it can never pull anybody back. + // The two facts every module below reads, worked out once. Both are + // pure functions of the committed state — "already walking home" is + // read off the standing order, which is the AI's only memory and one + // that survives save/restore because it is part of the world. bool retreats = IsRetreating(in unit, in posture, threatCells); + bool arrived = HasArrivedAtTheStagingCell(in unit, in posture); + + GoalKind goal = ResolveGoal(in unit, in posture, hqCellX, hqCellY, retreats, arrived); - bool marches = !retreats - && (posture.StagingCellX < 0 // no staging cell resolved - || posture.WaveReady // the wave launches this decision - || IsCommittedToTheWave(in unit, hqCellX, hqCellY)); // already out with an earlier wave - - // The one order a retreating unit still needs: its pursuer. - // Zero does not clear the march target it is carrying — see - // NearestThreatRaw for why leaving it stale silenced the unit for - // the whole way home. A WAITING reinforcement keeps getting zero: - // it holds no stale order to overwrite (it never marched), and - // finding F001 is explicit that aiming while standing still is - // worse than letting D-087 acquire. - uint retreatTargetRaw = retreats + // The pursuer, and only for the goals that carry one. + // + // Zero does not CLEAR a march target a unit is carrying — see + // NearestThreatRaw for why leaving it stale silenced a retreating + // unit for the whole way home. So Retreat overwrites the stale order + // with the thing chasing it, and Hold does the same for a unit that + // ran home and is still under the rule. A fresh reinforcement gets + // zero and should: it holds no stale order to overwrite, and aiming + // while standing still is worse than letting D-087 acquire (F001). + uint pursuerRaw = goal == GoalKind.Retreat || (goal == GoalKind.Hold && retreats) ? NearestThreatRaw(in unit, threatCells, threatRaws) : 0u; - if (marches) - { - return new UnitAssignment - { - EntityRaw = entityRaw, - AttackTargetRaw = posture.TargetRaw, - MoveCellX = posture.MoveCellX, - MoveCellY = posture.MoveCellY, - }; - } + return ApplyGoal(goal, entityRaw, in posture, pursuerRaw); + } - // Reinforcement that has ARRIVED: no order at all. - // - // This is not an optimisation, it is the difference between a wave - // and a stutter. Arrival clears TargetGridPos through Stop(), so - // the re-issue suppression in SubmitAssignments stops matching and - // the same move order goes out again every single cadence. Measured - // before this branch existed: 40 actions per minute against 23 for - // the shipped AI, for units that were standing still. Intent churn - // without a change of behaviour is exactly what sank DefendBase - // (journal V002), and the fix is to say nothing when there is - // nothing to say. - // "Arrived" means standing there, not merely being there. A unit - // that is inside the tolerance but still WALKING is walking - // somewhere else — saying nothing to it lets it carry on out of - // the ring, which is the opposite of what both rules want. A test - // found this: a wounded unit twelve cells from its HQ kept its - // march order and walked on toward the enemy, because it happened - // to pass within four cells of the staging cell. - if (!unit.IsMoving && IsAtTheStagingCell(in unit, in posture)) - { - return new UnitAssignment - { - EntityRaw = entityRaw, - AttackTargetRaw = retreatTargetRaw, - MoveCellX = -1, - MoveCellY = -1, - }; - } + /// + /// WHICH GOAL THIS UNIT IS UNDER — the four conditions, in priority + /// order, exactly as numbers them. + /// + /// TWO CLAUSES ARE NOT OBVIOUS AND BOTH ARE LOAD-BEARING. + /// + /// + /// Retreat steps aside for a unit that has arrived. + /// Getting home ENDS the retreat: from there it is an ordinary waiting + /// unit that leaves with the next wave, wounded or not. MS-1 units never + /// heal (Repair validates its target as a completed BUILDING), so + /// a rule that kept them under Retreat until they recovered would + /// pile the wounded up at home, occupy the army cap with them, and never + /// fill another wave. This clause is that rule, written where it can be + /// read. + /// Attack asks not to be retreating. A wounded unit + /// that is already outside the staging ring satisfies every other half + /// of the march test, so without this the pull-back could never reach + /// the one unit it exists for. Retreat has to outrank "you are out with + /// the wave, keep going". + /// + /// + private GoalKind ResolveGoal( + in UnitState unit, in ArmyPosture posture, int hqCellX, int hqCellY, + bool retreats, bool arrived) + { + if (retreats && !arrived) return GoalKind.Retreat; + if (!retreats && IsFitToMarch(in unit, in posture, hqCellX, hqCellY)) return GoalKind.Attack; + if (arrived) return GoalKind.Hold; + return GoalKind.Advance; + } - // Reinforcement still on its way: walk to the staging cell. - // - // NO EXPLICIT ATTACK TARGET while waiting, and that is a - // consequence of finding F001, not an oversight. An AttackTarget - // is released only by the target's death — Stop() leaves it - // standing — so a unit that is NOT closing the distance holds a - // stale order and stops firing, while the D-087 auto-acquisition - // would have shot at whatever came into range. Aiming is right - // while a unit walks toward what it aims at (journal V003), and a - // waiting unit does not. - // - // A RETREATING unit is the exception, and for the same reason - // rather than against it: it is not a fresh reinforcement, it is - // already carrying a march target it can no longer reach. Silence - // does not release that order, so silence is what kept it from - // firing. It gets its pursuer instead (retreatTargetRaw). - return new UnitAssignment + /// + /// Whether the wave carries this unit forward: no staging cell was + /// resolved at all, or the wave launches this decision, or the unit is + /// already out with an earlier one and is not called back. + /// + private bool IsFitToMarch(in UnitState unit, in ArmyPosture posture, int hqCellX, int hqCellY) + { + return posture.StagingCellX < 0 + || posture.WaveReady + || IsCommittedToTheWave(in unit, hqCellX, hqCellY); + } + + /// + /// True when the unit is STANDING at the staging cell — not merely + /// standing near it while walking somewhere else. + /// + /// The distinction was found by a test, not reasoned out: a wounded unit + /// twelve cells from its HQ kept its march order and walked on toward + /// the enemy, because its route happened to pass within four cells of + /// the staging point. "Is there" and "has arrived" are different + /// questions, and only the second one may buy silence. + /// + /// + private bool HasArrivedAtTheStagingCell(in UnitState unit, in ArmyPosture posture) + { + return posture.StagingCellX >= 0 && !unit.IsMoving && IsAtTheStagingCell(in unit, in posture); + } + + /// + /// THE EFFECT OF A GOAL — one table, four rows, no conditions. + /// + /// Every row is a pure function of the goal, the posture and the pursuer + /// the caller worked out, which is what makes a forced goal safe: a + /// panel that names Retreat for a healthy unit gets the orders + /// Retreat always produces, not a state the AI has no code for. + /// + /// + /// The two "no order" values are a real part of the vocabulary and not + /// a missing case: attack target 0 submits no attack intent and leaves + /// the D-087 auto-acquisition its pick, move cell -1 leaves the unit + /// walking wherever it already was. Hold is built out of both, + /// and its silence is the whole reason a wave looks like a wave instead + /// of a stutter. + /// + /// + private static UnitAssignment ApplyGoal( + GoalKind goal, uint entityRaw, in ArmyPosture posture, uint pursuerRaw) + { + switch (goal) { - EntityRaw = entityRaw, - AttackTargetRaw = retreatTargetRaw, - MoveCellX = posture.StagingCellX, - MoveCellY = posture.StagingCellY, - }; + case GoalKind.Retreat: + return new UnitAssignment + { + EntityRaw = entityRaw, + AttackTargetRaw = pursuerRaw, + MoveCellX = posture.StagingCellX, + MoveCellY = posture.StagingCellY, + }; + + case GoalKind.Attack: + return new UnitAssignment + { + EntityRaw = entityRaw, + AttackTargetRaw = posture.TargetRaw, + MoveCellX = posture.MoveCellX, + MoveCellY = posture.MoveCellY, + }; + + case GoalKind.Hold: + return new UnitAssignment + { + EntityRaw = entityRaw, + AttackTargetRaw = pursuerRaw, + MoveCellX = -1, + MoveCellY = -1, + }; + + default: + // Advance — and GoalKind.None with it. A mask that names + // None never reaches here (it means "leave it to the AI"), + // so the fall-through is the walk to the staging cell. + return new UnitAssignment + { + EntityRaw = entityRaw, + AttackTargetRaw = 0u, + MoveCellX = posture.StagingCellX, + MoveCellY = posture.StagingCellY, + }; + } } /// From 4822213e6e7a9e2375808070fa5346de05652b78 Mon Sep 17 00:00:00 2001 From: Aaron Hieronymus Date: Mon, 10 Aug 2026 19:29:26 +0200 Subject: [PATCH 2/5] feat(ai): das Goal mitlesen und von aussen vorgeben koennen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei optionale Naehte an der Skirmish-KI, die der ausgelieferte Pfad nie fuellt — MatchRunner uebergibt keine von beiden, die Referenzen bleiben null, und die Null-Pruefung ist der ganze Aufwand im Spiel. IAiGoalObserver laesst mitlesen, welches Goal eine Einheit bekommen hat UND mit welchen Zahlen die Bedingung entschieden hat: Leben gegen die Rueckzugsschwelle, Abstand gegen die Sammeltoleranz, Abstand zum HQ gegen den Ring. Jede Bedingung im Katalog ist ein ganzzahliger Vergleich, also ist der Abstand zum naechsten Goal exakte Arithmetik und keine Schaetzung. Es ist ein Rueckruf und kein Feld auf dem System: ein Puffer "was ich zuletzt entschieden habe" waere genau das Gedaechtnis, das der ganze Entwurf vermeidet, und das erste, was eine spaetere Regel lesen wollte. IAiGoalOverride erlaubt es, einem Goal von aussen vorzugreifen — als EINGABE der Entscheidung, wie das Profil, nicht als gespeicherter Zustand. Die Maske ersetzt die Wahl und nie die Wirkung: ein erzwungenes Goal erzeugt genau die Befehle, die dieses Goal immer erzeugt, damit kein Werkzeug ein Verhalten vorfuehren kann, fuer das die KI keinen Code hat. Wozu ueberhaupt: bisher war der einzige Weg, das WARUM einer Einheit zu zeigen, die Regeln neben der Aufzeichnung nachzubauen und das Ergebnis "hergeleitet" zu nennen. Ein Diagnosewerkzeug, das eine zweite, leicht abweichende Fassung der Regeln zeigt, ist schlimmer als eines, das nichts zeigt. WaveStrengthGate bekommt eine IsReady-Ueberladung, die die Schwelle herausgibt. Als Ueberladung und nicht als zweite Rechnung beim Aufrufer, damit der Vergleichsoperator weiterhin genau einmal existiert. Beide Naehte sind byte-identisch nachgewiesen: ein Lauf mit Beobachter und ein Lauf mit leerer Maske erreichen denselben Endzustand auf demselben Tick wie einer ohne. Dazu die Gegenprobe, dass eine Maske, die etwas benennt, die Partie tatsaechlich veraendert — sonst waere die erste Zusicherung leer. 715/715 im Spiel (707 plus acht neue). Keine der vier Baseline-Dateien angefasst. --- .../Scripts/AI.Data/IAiGoalObserver.cs | 193 ++++++ .../Scripts/AI.Data/IAiGoalObserver.cs.meta | 2 + .../Scripts/AI.Data/IAiGoalOverride.cs | 41 ++ .../Scripts/AI.Data/IAiGoalOverride.cs.meta | 2 + .../_Project/Scripts/AI/SkirmishAiSystem.cs | 162 +++++- .../_Project/Scripts/AI/WaveStrengthGate.cs | 35 +- CHANGELOG.md | 22 + tools/Nova.SimRunner.Tests/SkirmishAiTests.cs | 23 +- .../Nova.SimRunner.Tests/SkirmishGoalTests.cs | 547 ++++++++++++++++++ 9 files changed, 1015 insertions(+), 12 deletions(-) create mode 100644 Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs create mode 100644 Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs.meta create mode 100644 Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs create mode 100644 Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs.meta create mode 100644 tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs diff --git a/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs b/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs new file mode 100644 index 0000000..912bd77 --- /dev/null +++ b/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs @@ -0,0 +1,193 @@ +namespace Nova.AI.Data +{ + /// Which rule the wave gate answered with this decision. + public enum WaveGateMode : byte + { + /// Waves are off (waveSize 1): every unit is its own wave. + Off = 0, + + /// The threshold is a number of gathered units. + Count = 1, + + /// The threshold is a sum of combat points (r6). + Strength = 2, + } + + /// + /// What the army as a whole decided this cadence, with the numbers the + /// decision was made from. + /// + /// The numbers are the point. "The wave waits" explains nothing; "the ring + /// holds 1.060 of the 1.200 points it needs" says how far off it is and in + /// which direction the next unit moves it. + /// + /// + public readonly struct AiArmyGoal + { + /// False when the army does not act at all — below the squad threshold, or no committed team view. + public readonly bool Engages; + + /// The scored target every marching unit shoots at; 0 when nothing enemy is visible. + public readonly uint TargetRaw; + + /// Where the army walks; -1 while it does not act. + public readonly int MoveCellX; + + /// See . + public readonly int MoveCellY; + + /// Where reinforcements gather; -1 when waves are off or the army does not act. + public readonly int StagingCellX; + + /// See . + public readonly int StagingCellY; + + /// Whether what waits in the staging ring is enough for the wave to march. + public readonly bool WaveReady; + + /// Which rule answered — the unit of measure of . + public readonly WaveGateMode WaveMode; + + /// Living combat units inside the staging ring. + public readonly int Gathered; + + /// Living combat units outside it — an earlier wave, never called back. + public readonly int Committed; + + /// Summed combat points of the gathered units. + public readonly long GatheredStrength; + + /// + /// What the ring has to hold before the wave marches — points under + /// , heads under + /// , 0 while waves are off. Already + /// capped by what production can still deliver, so the difference to + /// what is gathered is the honest distance to the march. + /// + public readonly long WaveThreshold; + + public AiArmyGoal( + bool engages, uint targetRaw, int moveCellX, int moveCellY, + int stagingCellX, int stagingCellY, bool waveReady, WaveGateMode waveMode, + int gathered, int committed, long gatheredStrength, long waveThreshold) + { + Engages = engages; + TargetRaw = targetRaw; + MoveCellX = moveCellX; + MoveCellY = moveCellY; + StagingCellX = stagingCellX; + StagingCellY = stagingCellY; + WaveReady = waveReady; + WaveMode = waveMode; + Gathered = gathered; + Committed = committed; + GatheredStrength = gatheredStrength; + WaveThreshold = waveThreshold; + } + } + + /// + /// What one unit was told to do this cadence, which goal said so, and the + /// measured quantities every goal condition compared against a profile + /// value. + /// + /// EVERY CONDITION IN THE GOAL CATALOGUE IS AN INTEGER COMPARISON, so the + /// distance to the next one is exact arithmetic rather than an estimate: + /// RetreatHealthPercent - HealthPercent is how much life a unit has + /// left before it turns, StagingToleranceCells - StagingDistanceCells + /// how many cells before it counts as arrived. That is what a panel can show + /// without repeating the rules in a second language and getting them subtly + /// wrong. + /// + /// + public readonly struct AiUnitGoal + { + /// The unit this is about. + public readonly uint EntityRaw; + + /// The goal that won. + public readonly GoalKind Goal; + + /// True when a goal mask named this unit — the goal was not the AI's own pick. + public readonly bool Forced; + + /// The attack order that goes out; 0 means no attack intent is submitted. + public readonly uint AttackTargetRaw; + + /// The move order that goes out; -1 means the unit is left where it walks. + public readonly int MoveCellX; + + /// See . + public readonly int MoveCellY; + + /// Health in percent of maximum — the left-hand side of the retreat rule. + public readonly int HealthPercent; + + /// Chebyshev cells to the nearest visible ARMED enemy, or -1 when none is visible or the retreat rule is off. + public readonly int ThreatDistanceCells; + + /// Chebyshev cells to the staging cell, or -1 when no staging cell is resolved. + public readonly int StagingDistanceCells; + + /// Chebyshev cells to the own headquarters — the left-hand side of the staging-ring test. + public readonly int HomeDistanceCells; + + public AiUnitGoal( + uint entityRaw, GoalKind goal, bool forced, uint attackTargetRaw, + int moveCellX, int moveCellY, int healthPercent, + int threatDistanceCells, int stagingDistanceCells, int homeDistanceCells) + { + EntityRaw = entityRaw; + Goal = goal; + Forced = forced; + AttackTargetRaw = attackTargetRaw; + MoveCellX = moveCellX; + MoveCellY = moveCellY; + HealthPercent = healthPercent; + ThreatDistanceCells = threatDistanceCells; + StagingDistanceCells = stagingDistanceCells; + HomeDistanceCells = homeDistanceCells; + } + } + + /// + /// Watches the skirmish AI decide, without being able to change what it + /// decides. + /// + /// WHY AN OBSERVER AND NOT A RECORD ON THE SYSTEM. The AI is a pure function + /// of the tick and the committed state; a buffer of "what I decided last" + /// hanging off it would be exactly the memory the whole design avoids, and + /// the first thing a later rule would be tempted to read. A callback carries + /// the same information out to whoever asked for it and leaves nothing + /// behind. + /// + /// + /// WHAT THIS IS FOR. Until now the only way to see WHY a unit did something + /// was to re-implement the rules beside the recording and label the result + /// derived — and a diagnostic tool that shows a second, slightly different + /// set of rules is worse than one that shows none. The goal is now recorded + /// where it is decided. + /// + /// + /// THE SHIPPED GAME NEVER PASSES ONE, so the null check is the entire cost + /// in the delivered path, and everything the observer wants computed is + /// computed behind it. + /// + /// + public interface IAiGoalObserver + { + /// + /// The army's posture for this decision — reported even when the army + /// does not act, because "it does not act" is the answer one is looking + /// for at least as often as the other one. + /// + void OnArmyGoal(byte slot, uint tick, in AiArmyGoal army); + + /// + /// One unit's goal for this decision. Called in the ascending entity + /// scan, only while the army acts — below the squad threshold no unit is + /// judged at all, which the army report has already said. + /// + void OnUnitGoal(byte slot, uint tick, in AiUnitGoal goal); + } +} diff --git a/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs.meta b/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs.meta new file mode 100644 index 0000000..028d5bf --- /dev/null +++ b/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c17ce524c35cb04d04afcd004d4ff92d diff --git a/Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs b/Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs new file mode 100644 index 0000000..c904599 --- /dev/null +++ b/Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs @@ -0,0 +1,41 @@ +namespace Nova.AI.Data +{ + /// + /// A goal forced onto single units from outside — the "override" half of the + /// lab's admin panel. + /// + /// THE MASK IS AN INPUT, NOT A STATE, and the distinction is the whole + /// reason this is an interface and not a field on the AI. The host answers + /// "for this entity, goal X" before each decision; the AI reads the answer + /// exactly the way it reads its profile, picks, and forgets. Nothing is + /// remembered between cadences, no block is added beside the world, and the + /// system stays a pure function of the tick, the committed state and its + /// inputs. A sidecar would have been an owner decision (D-ID); this is not + /// one. + /// + /// + /// THE SHIPPED GAME NEVER PASSES ONE. MatchRunner constructs the AI + /// without it, the reference is null, and the null check is the only cost — + /// which is what makes "with the mask compiled in" byte-identical to + /// "before", and therefore measurable at all. + /// + /// + /// A run in which somebody intervened is NOT A MEASUREMENT. It says what the + /// AI could have done, never what it does; the lab marks such a run and + /// keeps it out of the archive. + /// + /// + public interface IAiGoalOverride + { + /// + /// The goal forced on this entity, or to + /// leave the decision to the AI. + /// + /// Called once per combat unit per decision cadence, in the ascending + /// entity scan. It must be a pure function of the caller's own state for + /// the run to stay reproducible: same tick, same entity, same answer. + /// + /// + GoalKind ResolveGoal(uint entityRaw); + } +} diff --git a/Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs.meta b/Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs.meta new file mode 100644 index 0000000..597c185 --- /dev/null +++ b/Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7ac4128cf874d9d5d924d63446c281c4 diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs index bae92ec..ccf9109 100644 --- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs +++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs @@ -140,6 +140,19 @@ private struct SiteInfo private readonly FogOfWarSystem _fogOfWar; private readonly VictorySystem _victory; + /// + /// Watches the decisions; null in the shipped game. Read-only from the + /// AI's side — an observer cannot change what is decided, and nothing it + /// is told is kept here. + /// + private readonly IAiGoalObserver _goalObserver; + + /// + /// Goals forced from outside; null in the shipped game. An INPUT to the + /// decision, like the profile — never a memory of one. + /// + private readonly IAiGoalOverride _goalOverride; + public string Name => $"SkirmishAi_{_profile.FactionName}_P{_aiPlayerId}"; public byte AiPlayerId => _aiPlayerId; @@ -149,6 +162,16 @@ private struct SiteInfo /// the human host ingress — that is what keeps the AI on the canonical /// intent path with an authority-assigned slot, sequence and target /// tick. + /// + /// THE LAST TWO ARGUMENTS ARE OPTIONAL AND THE GAME PASSES NEITHER. + /// is told which goal each unit was + /// given, may name a goal for single + /// units before the decision is taken; both belong to the lab's admin + /// panel. They are constructor arguments rather than settable properties + /// so that neither can be swapped mid-match: the AI reads two references + /// that were fixed before the first tick, which is what lets the run + /// stay reproducible from its inputs. + /// /// public SkirmishAiSystem( byte aiPlayerId, @@ -159,8 +182,12 @@ public SkirmishAiSystem( ConstructionSystem construction, ProductionSystem production, FogOfWarSystem fogOfWar, - VictorySystem victory) + VictorySystem victory, + IAiGoalObserver goalObserver = null, + IAiGoalOverride goalOverride = null) { + _goalObserver = goalObserver; + _goalOverride = goalOverride; _aiPlayerId = aiPlayerId; _profile = profile; _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); @@ -188,7 +215,7 @@ public void ExecuteTick(Tick tick) { if (tick.Value % DecisionTickInterval != 0) return; if (_victory.IsDecided) return; - Decide(); + Decide(tick.Value); } public void Shutdown() @@ -199,7 +226,7 @@ public void Shutdown() // The decision loop (pure function of the committed state) // ------------------------------------------------------------------ - private void Decide() + private void Decide(uint tick) { FactionId faction = _economy.GetSlotFaction(_aiPlayerId); ref readonly PlayerEconomyState eco = ref _economy.GetPlayerEconomy(_aiPlayerId); @@ -481,6 +508,7 @@ private void Decide() // whole-army block applied. ---- ArmyPosture posture = ResolveArmyPosture( faction, barracksRaw, combatCount, combatUnits, hqCellX, hqCellY); + if (_goalObserver != null) ReportArmyGoal(tick, in posture); if (posture.Engages) { // Cells of the visible ARMED enemies, collected once per @@ -501,7 +529,7 @@ private void Decide() { UnitState unit = combatUnits[i]; assignments.Add(ResolveUnitAssignment( - combatRaws[i], in unit, in posture, hqCellX, hqCellY, threatCells, threatRaws)); + combatRaws[i], in unit, in posture, hqCellX, hqCellY, threatCells, threatRaws, tick)); } SubmitAssignments(assignments, combatUnits); } @@ -576,6 +604,29 @@ private struct ArmyPosture /// own wave. /// public bool WaveReady; + + // ---- what the verdict above was reached FROM ---- + // + // Not inputs to any rule: every one of these is already worked out + // where the gate is asked, and carrying it out of that method is + // what lets an observer say "1.060 of 1.200" instead of "waits". + // Nothing below is read by a decision, which is why adding them + // cannot move a single tick. + + /// Which rule the gate answered with — the unit of measure of . + public WaveGateMode WaveMode; + + /// Living combat units inside the staging ring; 0 while waves are off. + public int Gathered; + + /// Living combat units outside the ring — out with an earlier wave. + public int Committed; + + /// Summed combat points of the gathered units. + public long GatheredStrength; + + /// What the ring has to hold before the wave marches, already capped by what production can still deliver. + public long WaveThreshold; } /// @@ -646,6 +697,7 @@ private ArmyPosture ResolveArmyPosture( int waveSize = EffectiveWaveSize(); if (waveSize <= 1) return posture; + posture.WaveMode = WaveGateMode.Count; int gathered = 0; int committed = 0; long gatheredStrength = 0; @@ -686,11 +738,17 @@ private ArmyPosture ResolveArmyPosture( int producedStrength = wavePoints > 0 ? CombatStrength.OfFullHealth(faction, ProducedCombatRole) : 0; + posture.Gathered = gathered; + posture.Committed = committed; + posture.GatheredStrength = gatheredStrength; + if (wavePoints > 0 && producedStrength > 0) { + posture.WaveMode = WaveGateMode.Strength; posture.WaveReady = WaveStrengthGate.IsReady( wavePoints, gatheredStrength, gathered, committed, producedStrength, - _profile.TargetArmySize, canProduce: barracksRaw != 0); + _profile.TargetArmySize, canProduce: barracksRaw != 0, + out posture.WaveThreshold); return posture; } @@ -715,6 +773,7 @@ private ArmyPosture ResolveArmyPosture( if (reachable < 1) reachable = 1; int threshold = waveSize < reachable ? waveSize : reachable; + posture.WaveThreshold = threshold; posture.WaveReady = gathered >= threshold; return posture; } @@ -980,7 +1039,7 @@ private bool IsCommittedToTheWave(in UnitState unit, int hqCellX, int hqCellY) /// private UnitAssignment ResolveUnitAssignment( uint entityRaw, in UnitState unit, in ArmyPosture posture, int hqCellX, int hqCellY, - List threatCells, List threatRaws) + List threatCells, List threatRaws, uint tick) { // The two facts every module below reads, worked out once. Both are // pure functions of the committed state — "already walking home" is @@ -991,6 +1050,21 @@ private UnitAssignment ResolveUnitAssignment( GoalKind goal = ResolveGoal(in unit, in posture, hqCellX, hqCellY, retreats, arrived); + // The mask, if anybody handed one in. It replaces the pick and never + // the effect: a forced goal produces exactly the orders that goal + // always produces, so a panel cannot invent a behaviour the AI has + // no code for. + bool forced = false; + if (_goalOverride != null) + { + GoalKind wanted = _goalOverride.ResolveGoal(entityRaw); + if (wanted != GoalKind.None) + { + forced = true; + goal = wanted; + } + } + // The pursuer, and only for the goals that carry one. // // Zero does not CLEAR a march target a unit is carrying — see @@ -1004,7 +1078,14 @@ private UnitAssignment ResolveUnitAssignment( ? NearestThreatRaw(in unit, threatCells, threatRaws) : 0u; - return ApplyGoal(goal, entityRaw, in posture, pursuerRaw); + UnitAssignment assignment = ApplyGoal(goal, entityRaw, in posture, pursuerRaw); + + if (_goalObserver != null) + { + ReportUnitGoal(tick, in unit, in posture, in assignment, + goal, forced, hqCellX, hqCellY, threatCells); + } + return assignment; } /// @@ -1130,6 +1211,73 @@ private static UnitAssignment ApplyGoal( } } + // ------------------------------------------------------------------ + // Reporting — reached only when somebody attached an observer, which + // the shipped game never does. Every measurement below is taken HERE + // and nowhere else on the decision path, so none of it can cost the + // delivered build anything. + // ------------------------------------------------------------------ + + private void ReportArmyGoal(uint tick, in ArmyPosture posture) + { + _goalObserver.OnArmyGoal(_aiPlayerId, tick, new AiArmyGoal( + posture.Engages, posture.TargetRaw, posture.MoveCellX, posture.MoveCellY, + posture.StagingCellX, posture.StagingCellY, posture.WaveReady, posture.WaveMode, + posture.Gathered, posture.Committed, posture.GatheredStrength, posture.WaveThreshold)); + } + + /// + /// The unit's goal together with the QUANTITIES its conditions weighed — + /// health against the retreat percentage, distances against the staging + /// tolerance and the ring. A reader with these four numbers and the + /// profile can work out how far the unit is from its next goal without + /// anybody re-implementing the rules beside the recording. + /// + private void ReportUnitGoal( + uint tick, in UnitState unit, in ArmyPosture posture, in UnitAssignment assignment, + GoalKind goal, bool forced, int hqCellX, int hqCellY, List threatCells) + { + int cellX = GridCellOf(unit.Transform.PositionX); + int cellY = GridCellOf(unit.Transform.PositionY); + + _goalObserver.OnUnitGoal(_aiPlayerId, tick, new AiUnitGoal( + assignment.EntityRaw, + goal, + forced, + assignment.AttackTargetRaw, + assignment.MoveCellX, + assignment.MoveCellY, + unit.MaxHealth > 0 ? (int)((long)unit.CurrentHealth * 100 / unit.MaxHealth) : 0, + NearestThreatDistance(cellX, cellY, threatCells), + posture.StagingCellX < 0 + ? -1 + : Chebyshev(cellX, cellY, posture.StagingCellX, posture.StagingCellY), + Chebyshev(cellX, cellY, hqCellX, hqCellY))); + } + + /// + /// Cells to the nearest visible armed enemy, or -1 when none is visible + /// or the retreat rule never collected any. Report-only — the rule + /// itself asks a cheaper question (is ANY of them within the radius) and + /// keeps asking it. + /// + private static int NearestThreatDistance(int cellX, int cellY, List threatCells) + { + if (threatCells == null) return -1; + int nearest = -1; + for (int i = 0; i < threatCells.Count; i++) + { + int distance = Chebyshev(cellX, cellY, (int)(uint)threatCells[i], (int)(threatCells[i] >> 32)); + if (nearest < 0 || distance < nearest) nearest = distance; + } + return nearest; + } + + private static int Chebyshev(int ax, int ay, int bx, int by) + { + return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by)); + } + /// /// Turns assignments into intents: units sharing an order are /// collected into ONE entity-list command, and an assignment that diff --git a/Assets/_Project/Scripts/AI/WaveStrengthGate.cs b/Assets/_Project/Scripts/AI/WaveStrengthGate.cs index 6fcc1a9..95d9db8 100644 --- a/Assets/_Project/Scripts/AI/WaveStrengthGate.cs +++ b/Assets/_Project/Scripts/AI/WaveStrengthGate.cs @@ -101,8 +101,41 @@ public static bool IsReady( int armyCap, bool canProduce) { - return gatheredStrength >= Threshold( + return IsReady( + wavePoints, gatheredStrength, gathered, committed, producedStrength, armyCap, canProduce, + out _); + } + + /// + /// The same verdict, and the it was reached + /// against. + /// + /// THE NUMBER IS WORTH HANDING OUT because it is the one thing "the wave + /// waits" does not say: how far off it is. The gap between what the ring + /// holds and this value is what the admin panel shows as "another 140 + /// points before it marches", and it is exact rather than estimated — + /// every clause here is integer arithmetic. + /// + /// + /// It is an overload rather than a second computation at the call site + /// so the comparison operator still exists exactly once. Deleting the + /// clamp used to leave the whole suite green (see the class remarks); + /// two copies of the comparison would be the same trap one level up. + /// + /// + public static bool IsReady( + int wavePoints, + long gatheredStrength, + int gathered, + int committed, + int producedStrength, + int armyCap, + bool canProduce, + out long threshold) + { + threshold = Threshold( wavePoints, gatheredStrength, gathered, committed, producedStrength, armyCap, canProduce); + return gatheredStrength >= threshold; } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index cdd6d71..ca54104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,28 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de sie belegt keine Verbesserung. ### Geändert +- **Die Skirmish-KI benennt, was eine Einheit vorhat (`GoalKind`)** — der + Armeeschritt entschied bisher in einer if-Kette, in der keine Verzweigung + einen Namen hatte. Er wählt jetzt je Einheit und Kadenz **ein** Goal aus + einer festen Prioritätsliste — `Retreat`, `Attack`, `Hold`, `Advance` — und + wendet dessen Wirkung aus einer Tabelle an. **Es ist keine + Verhaltensänderung, und das ist der ganze Zweck des Schritts:** dieselben + vier Bedingungen in derselben Reihenfolge, dieselben Befehle. Der Nachweis + ist kein Test, sondern eine Zahl — die kanonische Partie entscheidet auf + demselben Tick mit demselben Endzustand, und die Artefakte eines + Laborlaufs sind byte-identisch bis auf die gemessene Laufzeit. Deshalb + kein `AiBehaviorId.Revision`-Bump und kein Profilfeld: Prioritäten im + Profil würden `ProfileHash` und damit den in jedes Artefakt gedruckten + Verhaltensbezeichner bewegen. Ein Goal ist **kein Zustand** — es wird je + Kadenz neu abgeleitet und nirgends gespeichert, die KI bleibt eine reine + Funktion des committeten Zustands, und es entsteht kein Sidecar-Block. + Dazu zwei optionale Nähte, die der ausgelieferte Pfad nie füllt und die + deshalb nichts kosten: `IAiGoalObserver` lässt einen Beobachter mitlesen, + welches Goal eine Einheit bekommen hat und mit welchen Zahlen die + Bedingung entschieden hat, `IAiGoalOverride` erlaubt es, einem Goal von + aussen vorzugreifen — als **Eingabe** der Entscheidung, nicht als + gespeicherter Zustand. Beide sind Werkzeug für die Diagnose im Labor; + `MatchRunner` übergibt keine von beiden - **16.7/C1: Fünf endliche Aetheriumfelder schaffen Knappheit (D-102)** — die zwei praktisch endlosen Startfelder werden durch zwei symmetrische Startfelder und zwei Expansionen mit je 9.000 AE sowie ein umkämpftes diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs index 2b21f81..e1c8a43 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs @@ -154,7 +154,11 @@ private static AiProfile WavesOff() waveStrengthPoints: 0); } - private static AiHost BuildAiHost(ulong seed, AiProfile? profile = null) + private static AiHost BuildAiHost( + ulong seed, + AiProfile? profile = null, + IAiGoalObserver goalObserver = null, + IAiGoalOverride goalOverride = null) { // Mirror of MatchRunner.InitializeMatch(seed, ..., enableSkirmishAi: true). var kernel = new SimulationKernel(new SimRandom(seed)); @@ -182,7 +186,8 @@ private static AiHost BuildAiHost(ulong seed, AiProfile? profile = null) ? new AiFactionProfile("Legion", profile.Value) : new AiFactionProfile("Legion", targetPowerMargin: 0, targetArmySize: 12, attackSquadThreshold: 6, targetHarvesterCount: 2), - aiIngress, entities, economy, construction, production, fogOfWar, victory); + aiIngress, entities, economy, construction, production, fogOfWar, victory, + goalObserver, goalOverride); kernel.RegisterSystem(economy); kernel.RegisterSystem(construction); @@ -254,9 +259,19 @@ private static void ApplyOpeningPosition(AiHost host) } } - internal static AiHost BuildMatch(ulong seed, AiProfile? profile = null) + /// + /// The canonical match. The last two arguments are the admin panel's + /// two seams and the shipped game passes neither — a run that hands in + /// an observer has to reach the SAME end state as one that does not, + /// which is what SkirmishGoalTests asserts. + /// + internal static AiHost BuildMatch( + ulong seed, + AiProfile? profile = null, + IAiGoalObserver goalObserver = null, + IAiGoalOverride goalOverride = null) { - AiHost host = BuildAiHost(seed, profile); + AiHost host = BuildAiHost(seed, profile, goalObserver, goalOverride); ApplyOpeningPosition(host); return host; } diff --git a/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs new file mode 100644 index 0000000..84e4bd6 --- /dev/null +++ b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs @@ -0,0 +1,547 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using Nova.AI.Data; +using Nova.Core; +using Nova.Simulation.Definitions; +using Nova.Simulation.State; + +namespace Nova.SimRunner.Tests +{ + /// + /// The named goals of the skirmish AI, and the two seams the lab's admin + /// panel hangs on them: an observer that watches the decision and a mask + /// that forces one. + /// + /// WHAT THIS SUITE IS ACTUALLY GUARDING. Naming the branches of the army + /// step was supposed to change nothing at all — the proof of that is the + /// pinned end state in and a lab run + /// whose artifacts stayed byte-identical, not an assertion here. What a test + /// CAN hold, and what the pin cannot, is the property that makes the names + /// worth having: the goal that is reported is the goal that produced the + /// orders. A panel drawing a goal the unit is not under would be worse than + /// a panel drawing nothing, because it looks like an answer. + /// + /// + /// The observer and the mask are both null in the shipped game + /// (MatchRunner passes neither), so two of the tests below simply + /// check that handing one in does not move the match. That is the whole + /// licence for compiling them into the delivered build. + /// + /// + [TestFixture] + public sealed class SkirmishGoalTests + { + private const byte AiSlot = 1; + + // ---------------------------------------------------------------- + // Test doubles + // ---------------------------------------------------------------- + + /// One reported unit decision, with the tick it was taken at. + private readonly struct UnitEntry + { + public readonly uint Tick; + public readonly AiUnitGoal Goal; + + public UnitEntry(uint tick, in AiUnitGoal goal) + { + Tick = tick; + Goal = goal; + } + } + + /// One reported army decision, with the tick it was taken at. + private readonly struct ArmyEntry + { + public readonly uint Tick; + public readonly AiArmyGoal Goal; + + public ArmyEntry(uint tick, in AiArmyGoal goal) + { + Tick = tick; + Goal = goal; + } + } + + /// + /// Keeps every decision it is told about. A pure sink: it reads nothing + /// back into the match, which is the property the byte-identity test + /// below actually verifies. + /// + private sealed class RecordingObserver : IAiGoalObserver + { + public readonly List Army = new List(); + public readonly List Units = new List(); + + public void OnArmyGoal(byte slot, uint tick, in AiArmyGoal army) + { + Army.Add(new ArmyEntry(tick, in army)); + } + + public void OnUnitGoal(byte slot, uint tick, in AiUnitGoal goal) + { + Units.Add(new UnitEntry(tick, in goal)); + } + + /// The army decision of the tick a unit decision belongs to. + public AiArmyGoal ArmyAt(uint tick) + { + for (int i = 0; i < Army.Count; i++) + { + if (Army[i].Tick == tick) return Army[i].Goal; + } + throw new InvalidOperationException( + $"no army decision was reported for tick {tick}, but a unit decision was"); + } + } + + /// The same goal for every unit — or none at all, which is the off setting. + private sealed class FixedMask : IAiGoalOverride + { + private readonly GoalKind _goal; + + public FixedMask(GoalKind goal) + { + _goal = goal; + } + + public GoalKind ResolveGoal(uint entityRaw) => _goal; + } + + // ---------------------------------------------------------------- + // The names describe the decision + // ---------------------------------------------------------------- + + /// + /// Every judged unit is under exactly one of the four goals, and the + /// three the canonical match has to contain all show up. + /// + /// Retreat is NOT among them and cannot be: the opponent of this + /// match is passive and owns no armed unit, so no threat is ever visible + /// and nothing ever turns back. It has its own test below with the + /// wound written in — the same reason + /// SkirmishAi_PullsWoundedUnitsBackTowardTheirOwnBase exists + /// beside the pinned end-to-end run. + /// + /// + [Test] + public void EveryJudgedUnitIsUnderExactlyOneNamedGoal() + { + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, goalObserver: observer); + host.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks); + + Assert.That(observer.Units, Is.Not.Empty, "no unit decision was reported at all"); + + bool attacked = false, held = false, advanced = false; + for (int i = 0; i < observer.Units.Count; i++) + { + GoalKind goal = observer.Units[i].Goal.Goal; + Assert.That(goal, Is.Not.EqualTo(GoalKind.None), + "a unit was judged and came out unnamed — the catalogue does not cover the decision"); + if (goal == GoalKind.Attack) attacked = true; + else if (goal == GoalKind.Hold) held = true; + else if (goal == GoalKind.Advance) advanced = true; + } + + Assert.Multiple(() => + { + Assert.That(attacked, Is.True, "no unit ever marched on the target"); + Assert.That(held, Is.True, "no reinforcement ever waited at the staging cell"); + Assert.That(advanced, Is.True, "no reinforcement ever walked to the staging cell"); + }); + } + + /// + /// THE ORDERS ARE THE ONES THE REPORTED GOAL PRODUCES — the single + /// property that makes a goal panel trustworthy. + /// + /// It is checked against the ARMY decision of the same tick rather than + /// against a copy of the effect table, so a change that moved an effect + /// without moving the name would fail here. That is the failure mode + /// worth catching: names and effects drifting apart is invisible in + /// every other artifact, because the match plays on regardless. + /// + /// + [Test] + public void TheOrdersThatWentOutAreTheOnesTheReportedGoalProduces() + { + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, goalObserver: observer); + host.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks); + + for (int i = 0; i < observer.Units.Count; i++) + { + UnitEntry entry = observer.Units[i]; + AiUnitGoal unit = entry.Goal; + AiArmyGoal army = observer.ArmyAt(entry.Tick); + string where = $"unit {unit.EntityRaw} at tick {entry.Tick} under {unit.Goal}"; + + switch (unit.Goal) + { + case GoalKind.Attack: + Assert.That(unit.MoveCellX, Is.EqualTo(army.MoveCellX), where); + Assert.That(unit.MoveCellY, Is.EqualTo(army.MoveCellY), where); + Assert.That(unit.AttackTargetRaw, Is.EqualTo(army.TargetRaw), where); + break; + + case GoalKind.Hold: + Assert.That(unit.MoveCellX, Is.EqualTo(-1), where + " — Hold must say nothing about movement"); + Assert.That(unit.MoveCellY, Is.EqualTo(-1), where); + break; + + case GoalKind.Advance: + Assert.That(unit.MoveCellX, Is.EqualTo(army.StagingCellX), where); + Assert.That(unit.MoveCellY, Is.EqualTo(army.StagingCellY), where); + Assert.That(unit.AttackTargetRaw, Is.EqualTo(0u), + where + " — a reinforcement on its way must not carry an explicit target (F001)"); + break; + + case GoalKind.Retreat: + Assert.That(unit.MoveCellX, Is.EqualTo(army.StagingCellX), where); + Assert.That(unit.MoveCellY, Is.EqualTo(army.StagingCellY), where); + break; + + default: + Assert.Fail(where + " — unnamed goal"); + break; + } + } + } + + /// + /// A unit under the retreat rule is reported as Retreat, and the + /// numbers reported beside it are the ones the rule compared. + /// + /// The setup is the one from + /// SkirmishAi_PullsWoundedUnitsBackTowardTheirOwnBase: the wave + /// has to be out, an ARMED enemy has to stand inside the danger radius, + /// and the wound is written into the state rather than shot in — this + /// asks what the AI decides about a wounded unit, not whether a rifle + /// can hit one. + /// + /// + [Test] + public void AWoundedUnitUnderFireIsReportedAsRetreat_WithTheNumbersTheRuleWeighed() + { + AiProfile shipped = AiProfiles.Ms1Canonical; + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, goalObserver: observer); + int ring = shipped.StagingDistanceCells + shipped.StagingToleranceCells; + + Assert.That(TryHqCell(host, AiSlot, out int hqX, out int hqY), Is.True); + + int budget = SkirmishAiTests.EndToEndBudgetTicks; + while (budget-- > 0 && FarthestCombatDistance(host, AiSlot, hqX, hqY) <= ring) host.Step(); + Assert.That(FarthestCombatDistance(host, AiSlot, hqX, hqY), Is.GreaterThan(ring), + "the army never marched, so nothing could turn back"); + + Assert.That(TryFirstCombatUnit(host, AiSlot, out EntityId woundedId, out int armyX, out int armyY), + Is.True); + Assert.That(host.Entities.TryGetUnit(woundedId, out UnitState marching), Is.True); + Assert.That(marching.TargetGridPos.IsValid, Is.True, "the subject has to be marching somewhere"); + + int aheadX = armyX + Math.Sign(marching.TargetGridPos.X - armyX) * shipped.RetreatDangerCells; + int aheadY = armyY + Math.Sign(marching.TargetGridPos.Y - armyY) * shipped.RetreatDangerCells; + SpawnEnemyInfantry(host, aheadX, aheadY); + + ref UnitState target = ref host.Entities.GetUnitRef(woundedId); + target.CurrentHealth = target.MaxHealth * (shipped.RetreatHealthPercent - 20) / 100; + + uint woundedRaw = UnitCommandStateView.ToRawEntityId(woundedId); + int before = observer.Units.Count; + RunToNextDecision(host); + + bool seen = false; + for (int i = before; i < observer.Units.Count; i++) + { + AiUnitGoal goal = observer.Units[i].Goal; + if (goal.EntityRaw != woundedRaw) continue; + seen = true; + + Assert.That(goal.Goal, Is.EqualTo(GoalKind.Retreat), + "a wounded unit with an armed enemy beside it was not reported as retreating"); + Assert.That(goal.HealthPercent, Is.LessThan(shipped.RetreatHealthPercent), + "the reported health is not the one that put the unit under the rule"); + Assert.That(goal.ThreatDistanceCells, Is.InRange(0, shipped.RetreatDangerCells), + "the reported threat distance does not explain why the rule fired"); + break; + } + Assert.That(seen, Is.True, "the wounded unit was never judged after the wound"); + } + + // ---------------------------------------------------------------- + // The two seams cost the shipped game nothing + // ---------------------------------------------------------------- + + /// + /// WATCHING IS NOT PLAYING. A run with an observer attached reaches the + /// same end state on the same tick as one without — otherwise the panel + /// would be describing a match that only exists while somebody looks at + /// it. + /// + [Test] + public void AnObserverDoesNotMoveTheMatch() + { + SkirmishAiTests.AiHost plain = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed); + uint plainDecided = plain.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks); + + SkirmishAiTests.AiHost watched = SkirmishAiTests.BuildMatch( + SkirmishAiTests.Seed, goalObserver: new RecordingObserver()); + uint watchedDecided = watched.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks); + + Assert.Multiple(() => + { + Assert.That(watchedDecided, Is.EqualTo(plainDecided)); + Assert.That(watched.Kernel.CalculateStateHash(), Is.EqualTo(plain.Kernel.CalculateStateHash()), + "attaching an observer changed the match"); + }); + } + + /// + /// An EMPTY mask is the off setting, and it has to be bit-exactly off: + /// for every unit means "the AI decides", + /// which is what the shipped game does by passing no mask at all. + /// + [Test] + public void AnEmptyGoalMaskDoesNotMoveTheMatch() + { + SkirmishAiTests.AiHost plain = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed); + uint plainDecided = plain.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks); + + SkirmishAiTests.AiHost masked = SkirmishAiTests.BuildMatch( + SkirmishAiTests.Seed, goalOverride: new FixedMask(GoalKind.None)); + uint maskedDecided = masked.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks); + + Assert.Multiple(() => + { + Assert.That(maskedDecided, Is.EqualTo(plainDecided)); + Assert.That(masked.Kernel.CalculateStateHash(), Is.EqualTo(plain.Kernel.CalculateStateHash()), + "a mask that names nothing still changed the match"); + }); + } + + // ---------------------------------------------------------------- + // …and a mask that names something is visible in the match + // ---------------------------------------------------------------- + + /// + /// A mask that holds every unit keeps the whole army inside the staging + /// ring for the entire match. + /// + /// Asserted on POSITIONS, not on the absence of intents: what the + /// override is for is being able to see the consequence of a goal, and + /// the consequence of Hold is that nobody goes anywhere. + /// + /// + /// THE CONTROL RUNS IN THE SAME TEST, on the same seed and the same + /// budget, because "the army stayed home" is the kind of assertion that + /// passes just as happily when nothing was built, when the match ended + /// early, or when the ring was measured against the wrong cell. Without + /// the unmasked half beside it, this test proves that a number is small. + /// + /// + [Test] + public void AMaskThatHoldsEveryUnitKeepsTheArmyInTheRing() + { + AiProfile shipped = AiProfiles.Ms1Canonical; + int ring = shipped.StagingDistanceCells + shipped.StagingToleranceCells; + + SkirmishAiTests.AiHost loose = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed); + loose.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks); + Assert.That(TryHqCell(loose, AiSlot, out int looseX, out int looseY), Is.True); + Assert.That(FarthestCombatDistance(loose, AiSlot, looseX, looseY), Is.GreaterThan(ring), + "the control run never left the ring either, so holding proves nothing"); + + SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch( + SkirmishAiTests.Seed, goalOverride: new FixedMask(GoalKind.Hold)); + host.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks); + + Assert.That(TryHqCell(host, AiSlot, out int hqX, out int hqY), Is.True); + Assert.That(CountCombatUnits(host, AiSlot), Is.GreaterThan(0), + "nothing was produced, so 'nobody left' says nothing"); + Assert.That(FarthestCombatDistance(host, AiSlot, hqX, hqY), Is.LessThanOrEqualTo(ring), + "a unit left the staging ring although every unit was held"); + } + + /// + /// A forced goal produces the ORDERS OF THAT GOAL and is reported as + /// forced. The mask replaces the pick, never the effect — so a panel + /// cannot conjure a behaviour the AI has no code for. + /// + [Test] + public void AForcedGoalProducesTheOrdersOfThatGoal_AndSaysItWasForced() + { + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch( + SkirmishAiTests.Seed, + goalObserver: observer, + goalOverride: new FixedMask(GoalKind.Advance)); + host.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks); + + Assert.That(observer.Units, Is.Not.Empty, "no unit decision was reported at all"); + for (int i = 0; i < observer.Units.Count; i++) + { + UnitEntry entry = observer.Units[i]; + AiUnitGoal unit = entry.Goal; + AiArmyGoal army = observer.ArmyAt(entry.Tick); + + Assert.That(unit.Goal, Is.EqualTo(GoalKind.Advance), + $"unit {unit.EntityRaw} at tick {entry.Tick} kept its own goal against the mask"); + Assert.That(unit.Forced, Is.True, "the report does not admit that the goal was forced"); + Assert.That(unit.MoveCellX, Is.EqualTo(army.StagingCellX)); + Assert.That(unit.MoveCellY, Is.EqualTo(army.StagingCellY)); + } + } + + // ---------------------------------------------------------------- + // The army report + // ---------------------------------------------------------------- + + /// + /// The wave verdict and the numbers reported beside it are the SAME + /// arithmetic: what the ring holds against the threshold it is measured + /// with. That is what lets a panel say "another 140 points" instead of + /// repeating the gate's rules in a second language and getting them + /// subtly wrong — which is exactly what the recorded player did before + /// these numbers existed, and it had to label every one of them derived. + /// + [Test] + public void TheArmyReportExplainsItsOwnWaveVerdict() + { + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, goalObserver: observer); + host.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks); + + bool weighed = false; + for (int i = 0; i < observer.Army.Count; i++) + { + AiArmyGoal army = observer.Army[i].Goal; + if (!army.Engages) continue; + + switch (army.WaveMode) + { + case WaveGateMode.Strength: + weighed = true; + Assert.That(army.WaveReady, Is.EqualTo(army.GatheredStrength >= army.WaveThreshold), + $"tick {observer.Army[i].Tick}: the verdict does not follow from the reported numbers"); + break; + + case WaveGateMode.Count: + Assert.That(army.WaveReady, Is.EqualTo(army.Gathered >= army.WaveThreshold), + $"tick {observer.Army[i].Tick}: the verdict does not follow from the reported numbers"); + break; + + default: + Assert.That(army.WaveReady, Is.True, "waves are off, so every unit is its own wave"); + break; + } + } + + Assert.That(weighed, Is.True, + "the shipped profile measures the wave in strength, and no such decision was reported"); + } + + // ---------------------------------------------------------------- + // Deterministic read helpers (ascending entity index) + // ---------------------------------------------------------------- + + 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; + } + + private static int FarthestCombatDistance(SkirmishAiTests.AiHost host, byte slot, int cellX, int cellY) + { + int farthest = 0; + 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 || !IsCombat(u.Role)) continue; + int distance = Math.Max( + Math.Abs(SimFixed.WorldToGrid(u.Transform.PositionX) - cellX), + Math.Abs(SimFixed.WorldToGrid(u.Transform.PositionY) - cellY)); + if (distance > farthest) farthest = distance; + } + return farthest; + } + + private static int CountCombatUnits(SkirmishAiTests.AiHost host, byte slot) + { + int count = 0; + 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 && IsCombat(u.Role)) count++; + } + return count; + } + + private static bool TryFirstCombatUnit( + SkirmishAiTests.AiHost host, byte slot, out EntityId id, 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 || !IsCombat(u.Role)) continue; + id = u.Id; + cellX = SimFixed.WorldToGrid(u.Transform.PositionX); + cellY = SimFixed.WorldToGrid(u.Transform.PositionY); + return true; + } + id = EntityId.Invalid; + cellX = -1; + cellY = -1; + return false; + } + + private static void SpawnEnemyInfantry(SkirmishAiTests.AiHost host, int cellX, int cellY) + { + const byte enemySlot = 0; + FactionId faction = host.Economy.GetSlotFaction(enemySlot); + Assert.That(SimDefinitions.TryGetUnit(faction, UnitRole.BasicInfantry, out SimUnitDefinition def), Is.True); + host.Entities.SpawnUnit( + enemySlot, + new Transform2D(SimFixed.FromInt(cellX), SimFixed.FromInt(cellY)), + def.MoveSpeed, + maxHealth: def.MaxHealth, + role: UnitRole.BasicInfantry); + } + + /// + /// To the next decision cadence and two ticks further, so the sealed + /// intent has landed. Not further: the subject keeps walking, and a long + /// window lets it leave the danger radius on its own. + /// + private static void RunToNextDecision(SkirmishAiTests.AiHost host) + { + ushort cadence = host.Ai.DecisionTickInterval; + do + { + host.Step(); + } + while (host.Kernel.CurrentTick.Value % cadence != 0); + host.Step(); + host.Step(); + } + + private static bool IsCombat(UnitRole role) => + role >= UnitRole.BasicInfantry && role <= UnitRole.Artillery; + } +} From 1d330a05eb130a025f7dcecb8fddf3f69243a84d Mon Sep 17 00:00:00 2001 From: arn-c0de Date: Mon, 10 Aug 2026 20:48:41 +0200 Subject: [PATCH 3/5] feat(ai): Sammeln abbrechen, wenn die eigene Basis brennt Neues Goal DefendHome (r7 -> r8): wer noch im Sammelring steht und nicht zurueckzieht, marschiert zur statischen HQ-Zelle und zielt auf den naechsten sichtbaren bewaffneten Gegner. Wer draussen ist, marschiert weiter -- die r3-Regel "Einheiten draussen werden nie zurueckgerufen" bleibt, die Welle wird unterbrochen, nicht freigegeben. Der Defekt ist so alt wie der Sammelpunkt: eine angekommene Einheit bekommt absichtlich keinen Befehl und haengt allein an der D-087-Auto- Acquisition, die 6 bzw. 7 Zellen reicht -- der Sammelpunkt liegt 12 vom HQ. Gemessen: das Legions-HQ nimmt ueber 766 Ticks 327 Treffer, waehrend die eigenen Einheiten im Median 13 Zellen entfernt unter Hold stehen und keine einzige angreift. Profilfeld defendHomeCells (ausgeliefert 10, 0 = aus). Auf 0 beidseitig ergibt die kanonische Partie bitgenau r7: Tick 3213, 0xE002DD893916967B. Der Bezeichner bewegt beide Haelften -- die Revision, weil Entscheidungen sich aendern, und ProfileHash, weil die Regel mit ihrer Aus-Stellung ausgeliefert wird. Wehrlosigkeit im Beschussfenster 96 % -> 60 %, Partie 3213 -> 6490 Ticks, eigene Verluste 18 -> 60: die Regel verschiebt die Niederlage, sie wendet sie nicht ab. 31 Beurteilungen kosten 25 Befehle auf eine einzige Zielzelle -- das ist die Korrektur gegenueber DefendBase (Journal V002, +23 % Intents an einem beweglichen Ziel). Bei bedrohter Basis steht keine Einheit im Ring mehr unter Hold. Am Player angesehen: Nova.AiLab-goal-base-defense-r8-20260810 https://youtu.be/pD9NXtTutmM Im laufenden Spiel gesehen: nein. --- .../_Project/Scripts/AI.Data/AiBehaviorId.cs | 17 +- Assets/_Project/Scripts/AI.Data/AiProfile.cs | 45 +++- Assets/_Project/Scripts/AI.Data/AiProfiles.cs | 30 ++- Assets/_Project/Scripts/AI.Data/GoalKind.cs | 60 ++++- .../Scripts/AI.Data/IAiGoalObserver.cs | 15 +- .../_Project/Scripts/AI/AiFactionProfile.cs | 3 +- .../_Project/Scripts/AI/SkirmishAiSystem.cs | 146 ++++++++-- CHANGELOG.md | 85 ++++-- tools/Nova.SimRunner.Tests/AiProfileTests.cs | 3 +- tools/Nova.SimRunner.Tests/SkirmishAiTests.cs | 42 ++- .../Nova.SimRunner.Tests/SkirmishGoalTests.cs | 254 ++++++++++++++++++ 11 files changed, 636 insertions(+), 64 deletions(-) diff --git a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs index 121c130..a215c0f 100644 --- a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs +++ b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs @@ -74,6 +74,20 @@ public static class AiBehaviorId /// brings one in the pull request that gives it a rule. /// /// + /// r8 IS THAT PULL REQUEST, and the identifier moves twice over: the + /// revision here because decisions change, and + /// because the rule ships with the off switch + /// that makes it measurable one-sided at all (finding M001). + /// DefendHome is the first goal to carry a rule of its own — the + /// units still waiting in the staging ring break off and walk home when + /// a visible armed enemy comes within + /// of their headquarters. What + /// it fixes is a defect as old as the staging cell itself (r3), not one + /// r6 introduced: a unit that has arrived is deliberately given no + /// order, so it hangs entirely on an auto-acquisition that reaches six + /// or seven cells while the staging cell sits twelve from the base. + /// + /// /// r5 fixes two defects found in the review of r3/r4, both of which /// change decisions and therefore the end state: the wave now waits for /// what production can still deliver instead of a fixed cap (a single @@ -127,7 +141,7 @@ public static class AiBehaviorId /// retried an illegal placement forever once the all-of gate shipped. /// /// - public const int Revision = 7; + public const int Revision = 8; /// /// Hash over every value of the shipped profile. Domain-separated like @@ -178,6 +192,7 @@ public static ulong ComputeProfileHash(AiProfile profile) writer.WriteInt32(profile.RetreatHealthPercent); writer.WriteInt32(profile.RetreatDangerCells); writer.WriteInt32(profile.WaveStrengthPoints); + writer.WriteInt32(profile.DefendHomeCells); return writer.Digest(); } } diff --git a/Assets/_Project/Scripts/AI.Data/AiProfile.cs b/Assets/_Project/Scripts/AI.Data/AiProfile.cs index e727550..e30e882 100644 --- a/Assets/_Project/Scripts/AI.Data/AiProfile.cs +++ b/Assets/_Project/Scripts/AI.Data/AiProfile.cs @@ -176,6 +176,43 @@ namespace Nova.AI.Data /// public int RetreatDangerCells { get; } + /// + /// How near a visible ARMED enemy has to come to the own headquarters + /// before the units still waiting in the staging ring break off and + /// walk home to fight. 0 means off. + /// + /// WHY THE RULE HAS TO EXIST. A unit that has ARRIVED at the staging + /// cell is given no order at all — deliberately, because an order per + /// cadence to a standing unit is intent churn without a change of + /// behaviour. It therefore depends entirely on the D-087 + /// auto-acquisition, and that reaches exactly as far as its weapon: + /// six cells for Legion infantry, seven for an Alliance rifleman. The + /// staging cell sits — twelve — from + /// the headquarters. An attacker at the base is outside every one of + /// those ranges, so the waiting units do not ignore it: they cannot see + /// it. Measured in the canonical match: the Legion headquarters takes + /// 327 hits over 766 ticks while its own units stand a median of + /// thirteen cells away under Hold, and not one of them is attacking. + /// + /// + /// WHY TEN. It is the radius B003 was measured with, and it sits + /// between (eight, so inside the base) + /// and the staging ring (sixteen, so a defender is not summoned by a + /// skirmish at the gathering point). + /// + /// + /// THERE IS NO SECOND RADIUS. A hysteresis value — "stay home until the + /// enemy is fourteen cells out" — is the obvious second number and is + /// deliberately absent: both destinations are STATIC cells and the + /// re-issue suppression already swallows an unchanged order, so whether + /// the trigger flutters is a question for the intent column and not for + /// a precaution. That is the correction over the discarded + /// DefendBase (journal V002), which handed the WHOLE army a new + /// destination every cadence and paid 23 % more intents for it. + /// + /// + public int DefendHomeCells { get; } + // ---- target scoring ---- // // Four weights over ONE integer score, no scalar quality function: @@ -219,7 +256,8 @@ public AiProfile( int stagingToleranceCells, int retreatHealthPercent, int retreatDangerCells, - int waveStrengthPoints) + int waveStrengthPoints, + int defendHomeCells) { ProfileId = profileId ?? string.Empty; DecisionTickInterval = decisionTickInterval; @@ -240,6 +278,7 @@ public AiProfile( RetreatHealthPercent = retreatHealthPercent; RetreatDangerCells = retreatDangerCells; WaveStrengthPoints = waveStrengthPoints; + DefendHomeCells = defendHomeCells; } /// @@ -272,7 +311,8 @@ public bool Equals(AiProfile other) => && StagingToleranceCells == other.StagingToleranceCells && RetreatHealthPercent == other.RetreatHealthPercent && RetreatDangerCells == other.RetreatDangerCells - && WaveStrengthPoints == other.WaveStrengthPoints; + && WaveStrengthPoints == other.WaveStrengthPoints + && DefendHomeCells == other.DefendHomeCells; public override bool Equals(object obj) => obj is AiProfile other && Equals(other); @@ -299,6 +339,7 @@ public override int GetHashCode() hash = (hash * 397) ^ RetreatHealthPercent; hash = (hash * 397) ^ RetreatDangerCells; hash = (hash * 397) ^ WaveStrengthPoints; + hash = (hash * 397) ^ DefendHomeCells; return hash; } } diff --git a/Assets/_Project/Scripts/AI.Data/AiProfiles.cs b/Assets/_Project/Scripts/AI.Data/AiProfiles.cs index e323166..d17cc81 100644 --- a/Assets/_Project/Scripts/AI.Data/AiProfiles.cs +++ b/Assets/_Project/Scripts/AI.Data/AiProfiles.cs @@ -139,7 +139,31 @@ public static class AiProfiles // 0 switches the rule off and restores the head count exactly // (finding M001: a behaviour without an off setting cannot be // measured one-sided). - waveStrengthPoints: 1200); + waveStrengthPoints: 1200, + // Defend home (r8). Ten cells around the own headquarters: inside + // the base, and short of the staging ring at sixteen, so a skirmish + // at the gathering point does not summon anybody. + // + // THE RULE EXISTS BECAUSE OF A MEASURED HOLE, not a hunch. A unit + // that has arrived at the staging cell is given no order at all — + // on purpose, an order per cadence to a standing unit is churn — so + // it depends entirely on the D-087 auto-acquisition, and that + // reaches six cells for Legion infantry and seven for an Alliance + // rifleman. The staging cell sits twelve cells from the base. An + // attacker at the headquarters is outside all of it. In the + // canonical match the Legion headquarters takes 327 hits over 766 + // ticks while its own units stand a median of thirteen cells away + // under Hold, and not one of them attacks. + // + // The wave is INTERRUPTED, not released: the destination is the own + // headquarters, a static cell, so the defenders walk toward the + // fight rather than away from it and the re-issue suppression + // swallows every repeat. That is the correction over the discarded + // DefendBase (V002), which handed the whole army a moving target + // every cadence and paid 23 % more intents for it. + // + // 0 switches the rule off and restores r7 exactly (M001). + defendHomeCells: 10); /// /// The old AiFactionProfile constructor defaults (power margin @@ -173,6 +197,8 @@ public static class AiProfiles retreatDangerCells: 8, // Same reasoning again: there is no "legacy" strength value, the // wave counted heads. 0 is the off setting. - waveStrengthPoints: 0); + waveStrengthPoints: 0, + // And no legacy defence value either — there was no defence rule. + defendHomeCells: 0); } } diff --git a/Assets/_Project/Scripts/AI.Data/GoalKind.cs b/Assets/_Project/Scripts/AI.Data/GoalKind.cs index 2d86482..f6dddfa 100644 --- a/Assets/_Project/Scripts/AI.Data/GoalKind.cs +++ b/Assets/_Project/Scripts/AI.Data/GoalKind.cs @@ -18,14 +18,35 @@ namespace Nova.AI.Data /// can name a goal without pulling the simulation in behind it. /// /// - /// THE ORDER OF THE VALUES IS THE PRIORITY, highest first: when more than - /// one condition holds, the lower number wins. It is a fixed order and not a - /// profile value on purpose — priorities in the profile would move - /// AiProfile.ProfileHash and with it the behaviour identifier printed - /// into every lab artifact, and the first step of this strand has to be - /// provably behaviour-neutral (ROADMAP.md point 2). A module that needs an - /// off setting brings one with it, in the pull request that gives it a rule - /// worth switching off. + /// THE PRIORITY IS FIXED AND IT IS NOT THE NUMBER. When more than one + /// condition holds, the winner is decided by the order the tests are + /// written in SkirmishAiSystem.ResolveGoal, which is: + /// + /// + /// — a wounded unit outranks everything, or the + /// pull-back could never reach the one unit it exists for + /// — the base burning outranks gathering for + /// a wave that will leave it burning + /// + /// + /// + /// + /// + /// THE NUMBERS ARE APPENDED, NEVER RENUMBERED, and that is why the two came + /// apart. A value is written into goals.ndjson as a bare integer; + /// renumbering to keep value order equal to priority order would silently + /// re-label every recorded run — the panel would print attack where + /// the file means hold, which is precisely the display error a + /// diagnostic tool must not have. Columns are appended in the artifacts for + /// the same reason, and a goal is a column. + /// + /// + /// The priority is fixed in code and not a profile value: priorities in the + /// profile would have moved AiProfile.ProfileHash while the first + /// step of this strand still had to be provably behaviour-neutral + /// (ROADMAP.md point 2). A module that needs an off setting brings one with + /// it, in the pull request that gives it a rule worth switching off — + /// is the first that did. /// /// public enum GoalKind : byte @@ -67,5 +88,28 @@ public enum GoalKind : byte /// arming it (finding F001, journal V003). /// Advance = 4, + + /// + /// The base is under attack and this unit was waiting to leave it: + /// break off, walk to the own headquarters, shoot at whoever is nearest. + /// Outranks — gathering for a wave that marches + /// away from a burning base is the defect this goal exists for — and + /// gives way to , because a unit too wounded to + /// fight is no defender. + /// + /// ONLY UNITS STILL INSIDE THE STAGING RING. A wave that is already out + /// keeps marching: "units that are out are never called back" is the r3 + /// rule that made a wave a wave, and recalling them is the V002 failure + /// mode with a new name. + /// + /// + /// THE DESTINATION IS A STATIC CELL — the headquarters, which does not + /// move all match — and not the enemy. That is the whole difference to + /// the discarded DefendBase: a moving destination meant a fresh + /// order every cadence for every unit, 23 % more intents and a worse + /// match (journal V002). + /// + /// + DefendHome = 5, } } diff --git a/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs b/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs index 912bd77..d6169af 100644 --- a/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs +++ b/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs @@ -66,11 +66,24 @@ public readonly struct AiArmyGoal /// public readonly long WaveThreshold; + /// + /// A visible armed enemy stands within AiProfile.DefendHomeCells + /// of the headquarters, so everyone still in the ring breaks off and + /// defends (r8). Always false while the rule is off. + /// + /// APPENDED, like every column before it — a reader of an older file + /// keeps reading the columns it has. + /// + /// + public readonly bool HomeThreatened; + public AiArmyGoal( bool engages, uint targetRaw, int moveCellX, int moveCellY, int stagingCellX, int stagingCellY, bool waveReady, WaveGateMode waveMode, - int gathered, int committed, long gatheredStrength, long waveThreshold) + int gathered, int committed, long gatheredStrength, long waveThreshold, + bool homeThreatened) { + HomeThreatened = homeThreatened; Engages = engages; TargetRaw = targetRaw; MoveCellX = moveCellX; diff --git a/Assets/_Project/Scripts/AI/AiFactionProfile.cs b/Assets/_Project/Scripts/AI/AiFactionProfile.cs index a31374e..506feb5 100644 --- a/Assets/_Project/Scripts/AI/AiFactionProfile.cs +++ b/Assets/_Project/Scripts/AI/AiFactionProfile.cs @@ -80,7 +80,8 @@ public AiFactionProfile(string factionName, int targetPowerMargin = 30, int targ stagingToleranceCells: shipped.StagingToleranceCells, retreatHealthPercent: shipped.RetreatHealthPercent, retreatDangerCells: shipped.RetreatDangerCells, - waveStrengthPoints: shipped.WaveStrengthPoints); + waveStrengthPoints: shipped.WaveStrengthPoints, + defendHomeCells: shipped.DefendHomeCells); } /// Binds a faction name to a fully specified profile — the tuning path. diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs index ccf9109..bea4aa8 100644 --- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs +++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs @@ -502,28 +502,39 @@ private void Decide(uint tick) } } + // Cells of the visible ARMED enemies, collected once per decision. + // A local list, not a field: the system stays a pure function of + // the committed state, and nothing survives the decision. + // + // IT IS COLLECTED BEFORE THE POSTURE, and it has to be. The posture + // now answers "is the base under attack", which is a question about + // this list — and gathering it inside the per-unit loop below, where + // it used to live, would have meant asking after the answer was + // needed. + // + // TWO RULES SHARE IT, SO TWO RULES OPEN IT. The gate used to read + // RetreatHealthPercent alone; leaving it that way would have let + // retreat-off switch the defence off in silence, and the lab's + // `retreat-off` candidate would then measure something other than + // its name. + List threatCells = null; + List threatRaws = null; + if (_profile.Profile.RetreatHealthPercent > 0 || _profile.Profile.DefendHomeCells > 0) + { + threatCells = new List(); + threatRaws = new List(); + CollectVisibleThreats(threatCells, threatRaws); + } + // ---- (6) Army: resolve one posture for the army, one GOAL per // unit, then submit the resulting orders grouped. See the three - // steps below; the rules are exactly the ones the previous - // whole-army block applied. ---- + // steps below; the rules are the ones the previous whole-army + // block applied, plus the defence r8 adds to the catalogue. ---- ArmyPosture posture = ResolveArmyPosture( - faction, barracksRaw, combatCount, combatUnits, hqCellX, hqCellY); + faction, barracksRaw, combatCount, combatUnits, hqCellX, hqCellY, threatCells); if (_goalObserver != null) ReportArmyGoal(tick, in posture); if (posture.Engages) { - // Cells of the visible ARMED enemies, collected once per - // decision and only while the retreat rule is on. A local - // list, not a field: the system stays a pure function of the - // committed state, and nothing survives the decision. - List threatCells = null; - List threatRaws = null; - if (_profile.Profile.RetreatHealthPercent > 0) - { - threatCells = new List(); - threatRaws = new List(); - CollectVisibleThreats(threatCells, threatRaws); - } - var assignments = new List(combatUnits.Count); for (int i = 0; i < combatUnits.Count; i++) { @@ -605,6 +616,25 @@ private struct ArmyPosture /// public bool WaveReady; + /// + /// The own headquarters cell — where a defender walks. Static for + /// the whole match, which is the property the rule is built on: a + /// destination that does not move produces ONE order and not a + /// stream of them. + /// + public int HomeCellX; + + /// See . + public int HomeCellY; + + /// + /// A visible ARMED enemy stands within + /// of the headquarters, so + /// the units still waiting in the ring break off (r8). Always false + /// while the rule is off, which is what keeps that path identical. + /// + public bool HomeThreatened; + // ---- what the verdict above was reached FROM ---- // // Not inputs to any rule: every one of these is already worked out @@ -656,7 +686,7 @@ private struct UnitAssignment /// private ArmyPosture ResolveArmyPosture( FactionId faction, uint barracksRaw, int combatCount, List combatUnits, - int hqCellX, int hqCellY) + int hqCellX, int hqCellY, List threatCells) { var posture = new ArmyPosture { @@ -666,6 +696,9 @@ private ArmyPosture ResolveArmyPosture( StagingCellX = -1, StagingCellY = -1, WaveReady = true, + HomeCellX = hqCellX, + HomeCellY = hqCellY, + HomeThreatened = IsHomeThreatened(hqCellX, hqCellY, threatCells), }; if (!posture.Engages) return posture; @@ -889,6 +922,42 @@ private bool IsRetreating(in UnitState unit, in ArmyPosture posture, List return false; } + /// + /// Whether a visible ARMED enemy stands within + /// of the own headquarters — the + /// whole trigger of (r8). + /// + /// THREE PROPERTIES, EACH WITH A REASON. Armed, because a + /// harvester at the fence is not an attack, and reacting to anything + /// that moves is exactly what sank DefendBase (journal V002) — + /// filters that way already. + /// Visible, because anything else is a look through the fog. + /// Around the headquarters, not around any building: the entity + /// scan has that cell in hand, the observed and measured case is the one + /// at the headquarters, and "every building" would need a reason as well + /// as a wider scan. If the measurement later shows attacks on the + /// refinery going through the same way, that arrives as its own number. + /// + /// + /// A cheap ANY question, deliberately: which enemy is nearest is the + /// pursuer's business (), and the trigger + /// does not need it. + /// + /// + private bool IsHomeThreatened(int hqCellX, int hqCellY, List threatCells) + { + int radius = _profile.Profile.DefendHomeCells; + if (radius <= 0 || threatCells == null) return false; + + for (int i = 0; i < threatCells.Count; i++) + { + int threatX = (int)(uint)threatCells[i]; + int threatY = (int)(threatCells[i] >> 32); + if (Math.Abs(hqCellX - threatX) <= radius && Math.Abs(hqCellY - threatY) <= radius) return true; + } + return false; + } + /// /// The cells of every ARMED enemy in the team's committed view, packed /// as (y << 32) | x. Unarmed entities are left out: a @@ -1074,7 +1143,8 @@ private UnitAssignment ResolveUnitAssignment( // ran home and is still under the rule. A fresh reinforcement gets // zero and should: it holds no stale order to overwrite, and aiming // while standing still is worse than letting D-087 acquire (F001). - uint pursuerRaw = goal == GoalKind.Retreat || (goal == GoalKind.Hold && retreats) + uint pursuerRaw = goal == GoalKind.Retreat || goal == GoalKind.DefendHome + || (goal == GoalKind.Hold && retreats) ? NearestThreatRaw(in unit, threatCells, threatRaws) : 0u; @@ -1108,6 +1178,14 @@ private UnitAssignment ResolveUnitAssignment( /// of the march test, so without this the pull-back could never reach /// the one unit it exists for. Retreat has to outrank "you are out with /// the wave, keep going". + /// DefendHome sits between the two, and both sides of + /// that are load-bearing. It gives way to Retreat because a + /// unit too wounded to fight is no defender — and it outranks + /// Attack because the units it is for would otherwise gather for + /// a wave that marches away from their burning base. It asks only about + /// units still INSIDE the ring: a wave that is already out keeps going, + /// which is the r3 rule that made a wave a wave and the V002 failure + /// mode if it were dropped. /// /// private GoalKind ResolveGoal( @@ -1115,7 +1193,13 @@ private GoalKind ResolveGoal( bool retreats, bool arrived) { if (retreats && !arrived) return GoalKind.Retreat; - if (!retreats && IsFitToMarch(in unit, in posture, hqCellX, hqCellY)) return GoalKind.Attack; + + // Asked once and read twice — the defence needs to know that the + // unit is still gathering, and the march test needs the same fact. + bool committed = IsCommittedToTheWave(in unit, hqCellX, hqCellY); + + if (!retreats && posture.HomeThreatened && !committed) return GoalKind.DefendHome; + if (!retreats && IsFitToMarch(in posture, committed)) return GoalKind.Attack; if (arrived) return GoalKind.Hold; return GoalKind.Advance; } @@ -1125,11 +1209,11 @@ private GoalKind ResolveGoal( /// resolved at all, or the wave launches this decision, or the unit is /// already out with an earlier one and is not called back. /// - private bool IsFitToMarch(in UnitState unit, in ArmyPosture posture, int hqCellX, int hqCellY) + private static bool IsFitToMarch(in ArmyPosture posture, bool committed) { return posture.StagingCellX < 0 || posture.WaveReady - || IsCommittedToTheWave(in unit, hqCellX, hqCellY); + || committed; } /// @@ -1197,6 +1281,23 @@ private static UnitAssignment ApplyGoal( MoveCellY = -1, }; + case GoalKind.DefendHome: + // The same shape Retreat has, aimed at the other static + // cell: walk to the headquarters, shoot the nearest armed + // enemy on the way. Static is the operative word — the + // headquarters does not move all match, so the re-issue + // suppression in SubmitAssignments swallows every repeat + // and a defence that lasts twenty cadences costs one order. + // DefendBase aimed at the ENEMY, which moves, and paid a + // fresh order per unit per cadence for it (journal V002). + return new UnitAssignment + { + EntityRaw = entityRaw, + AttackTargetRaw = pursuerRaw, + MoveCellX = posture.HomeCellX, + MoveCellY = posture.HomeCellY, + }; + default: // Advance — and GoalKind.None with it. A mask that names // None never reaches here (it means "leave it to the AI"), @@ -1223,7 +1324,8 @@ private void ReportArmyGoal(uint tick, in ArmyPosture posture) _goalObserver.OnArmyGoal(_aiPlayerId, tick, new AiArmyGoal( posture.Engages, posture.TargetRaw, posture.MoveCellX, posture.MoveCellY, posture.StagingCellX, posture.StagingCellY, posture.WaveReady, posture.WaveMode, - posture.Gathered, posture.Committed, posture.GatheredStrength, posture.WaveThreshold)); + posture.Gathered, posture.Committed, posture.GatheredStrength, posture.WaveThreshold, + posture.HomeThreatened)); } /// diff --git a/CHANGELOG.md b/CHANGELOG.md index ca54104..cc550b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,28 +65,69 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de sie belegt keine Verbesserung. ### Geändert -- **Die Skirmish-KI benennt, was eine Einheit vorhat (`GoalKind`)** — der - Armeeschritt entschied bisher in einer if-Kette, in der keine Verzweigung - einen Namen hatte. Er wählt jetzt je Einheit und Kadenz **ein** Goal aus - einer festen Prioritätsliste — `Retreat`, `Attack`, `Hold`, `Advance` — und - wendet dessen Wirkung aus einer Tabelle an. **Es ist keine - Verhaltensänderung, und das ist der ganze Zweck des Schritts:** dieselben - vier Bedingungen in derselben Reihenfolge, dieselben Befehle. Der Nachweis - ist kein Test, sondern eine Zahl — die kanonische Partie entscheidet auf - demselben Tick mit demselben Endzustand, und die Artefakte eines - Laborlaufs sind byte-identisch bis auf die gemessene Laufzeit. Deshalb - kein `AiBehaviorId.Revision`-Bump und kein Profilfeld: Prioritäten im - Profil würden `ProfileHash` und damit den in jedes Artefakt gedruckten - Verhaltensbezeichner bewegen. Ein Goal ist **kein Zustand** — es wird je - Kadenz neu abgeleitet und nirgends gespeichert, die KI bleibt eine reine - Funktion des committeten Zustands, und es entsteht kein Sidecar-Block. - Dazu zwei optionale Nähte, die der ausgelieferte Pfad nie füllt und die - deshalb nichts kosten: `IAiGoalObserver` lässt einen Beobachter mitlesen, - welches Goal eine Einheit bekommen hat und mit welchen Zahlen die - Bedingung entschieden hat, `IAiGoalOverride` erlaubt es, einem Goal von - aussen vorzugreifen — als **Eingabe** der Entscheidung, nicht als - gespeicherter Zustand. Beide sind Werkzeug für die Diagnose im Labor; - `MatchRunner` übergibt keine von beiden +- **Die Skirmish-KI benennt, was eine Einheit vorhat — und verteidigt damit + ihre Basis (`GoalKind`, `r7` → `r8`)** — zwei Schritte, die zusammengehören + und deshalb zusammen kommen: erst bekommt die Entscheidung eine **Form**, + dann bekommt die Form ihre erste eigene **Regel**. Die Reihenfolge ist der + Punkt — wer zuerst eine Regel und dabei die Form ändert, kann hinterher + nicht sagen, welche der beiden gewirkt hat. + + **Die Form, verhaltensneutral.** Der Armeeschritt entschied in einer + if-Kette, in der keine Verzweigung einen Namen hatte. Er wählt jetzt je + Einheit und Kadenz **ein** Goal aus einer festen Prioritätsliste und wendet + dessen Wirkung aus einer Tabelle an. Dieser Schritt für sich ist **keine** + Verhaltensänderung, und der Nachweis ist kein Test, sondern eine Zahl: die + kanonische Partie entscheidet auf demselben Tick mit demselben Endzustand, + die Artefakte eines Laborlaufs sind byte-identisch bis auf die gemessene + Laufzeit. Ein Goal ist **kein Zustand** — es wird je Kadenz neu abgeleitet + und nirgends gespeichert, die KI bleibt eine reine Funktion des committeten + Zustands, es entsteht kein Sidecar-Block. Dazu zwei optionale Nähte, die der + ausgelieferte Pfad nie füllt und die deshalb nichts kosten: + `IAiGoalObserver` lässt mitlesen, welches Goal eine Einheit bekommen hat und + mit welchen Zahlen die Bedingung entschieden hat, `IAiGoalOverride` erlaubt + es, einem Goal von aussen vorzugreifen — als **Eingabe** der Entscheidung, + nicht als gespeicherter Zustand. `MatchRunner` übergibt keine von beiden. + + **Die Regel: `DefendHome`.** Eine Einheit, die am Sammelpunkt angekommen + ist, bekommt absichtlich **keinen** Befehl und hängt damit allein an der + D-087-Auto-Acquisition. Die reicht so weit wie die Waffe: sechs Zellen bei + der Legions-Infanterie, sieben beim Allianz-Schützen. Der Sammelpunkt liegt + **zwölf** Zellen vom eigenen HQ. Ein Angreifer an der Basis war damit + ausserhalb jeder Reichweite — die Wartenden haben ihn nicht ignoriert, + **sie haben ihn nicht gesehen**. Gemessen in der kanonischen Partie: das + Legions-HQ nimmt über **766 Ticks 327 Treffer**, während die eigenen + Einheiten im Median **13 Zellen** entfernt unter `Hold` stehen und **keine + einzige** angreift. Der Defekt ist so alt wie der Sammelpunkt (`r3`). + Neues Goal `DefendHome` mit dem Profilfeld `defendHomeCells` (ausgeliefert + **10**, **0 = aus**): wer noch **im Sammelring** steht und nicht gerade + zurückzieht, marschiert zum eigenen HQ und zielt auf den nächsten + sichtbaren bewaffneten Gegner. Wer **draussen** ist, marschiert weiter — + die `r3`-Regel „Einheiten draussen werden nie zurückgerufen" bleibt, und + die Welle wird **unterbrochen, nicht freigegeben**. Das Ziel ist die + **statische** HQ-Zelle und ausdrücklich nicht der Gegner: genau daran ist + `DefendBase` gescheitert (+23 % Intents, schlechteres Spiel), weil ein + bewegliches Ziel jede Kadenz einen neuen Befehl für jede Einheit erzeugt. + Gemessen kostet die Regel **31 Beurteilungen 25 Befehle** auf **eine** + Zielzelle, und bei bedrohter Basis steht **keine** Einheit im Ring mehr + unter `Hold`. + + **Was die Regel bringt und was sie kostet.** Die Wehrlosigkeit im + Beschussfenster fällt von **96 % auf 60 %**, die Partie der Legion dauert + **3.213 → 6.490** Ticks. **Sie gewinnt sie trotzdem nicht**, und die eigenen + Verluste steigen von 18 auf 60 — die Regel verschiebt die Niederlage, sie + wendet sie nicht ab. Der Verhaltensbezeichner bewegt beide Hälften: + die Revision, weil Entscheidungen sich ändern, und `ProfileHash`, weil die + Regel mit ihrer Aus-Stellung ausgeliefert wird. `defendHomeCells: 0` auf + beiden Sitzen ergibt **bitgenau** die Partie von `r7` (Tick 3.213, + `0xE002DD893916967B`). + + **Am Player angesehen:** die Regel ist in der Laboraufnahme + `Nova.AiLab-goal-base-defense-r8-20260810` in Bewegung zu sehen — die + Wartenden lösen sich vom Sammelpunkt und marschieren zum eigenen HQ. Das ist + die Gegenaufnahme zu der, in der genau diese Einheiten weitersammeln, während + ihr Hauptquartier fällt. + **Im laufenden Spiel gesehen: nein.** Eine Aufzeichnung des Labors ist keine + gespielte Partie; alles oben ist gemessen, nicht gespielt. - **16.7/C1: Fünf endliche Aetheriumfelder schaffen Knappheit (D-102)** — die zwei praktisch endlosen Startfelder werden durch zwei symmetrische Startfelder und zwei Expansionen mit je 9.000 AE sowie ein umkämpftes diff --git a/tools/Nova.SimRunner.Tests/AiProfileTests.cs b/tools/Nova.SimRunner.Tests/AiProfileTests.cs index 19141c1..d7327c4 100644 --- a/tools/Nova.SimRunner.Tests/AiProfileTests.cs +++ b/tools/Nova.SimRunner.Tests/AiProfileTests.cs @@ -258,7 +258,8 @@ public void AProfileCanBeTunedThroughTheDataType() targetFinishWeight: 2, targetDistanceWeight: 5, waveSize: 5, stagingDistanceCells: 20, stagingToleranceCells: 3, retreatHealthPercent: 30, retreatDangerCells: 6, - waveStrengthPoints: 900); + waveStrengthPoints: 900, + defendHomeCells: 14); var bound = new AiFactionProfile("Legion", tuned); diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs index e1c8a43..309cd48 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs @@ -83,12 +83,26 @@ internal sealed class AiHost /// clock to the tick about to execute (its intents then target /// T+1), step, and advance the human session. /// + /// + /// How many intents the AI has put on the wire since the host was + /// built — the first number any behaviour change is judged by. + /// + /// DefendBase was measured back out again over 23 % more + /// intents and nothing else (journal V002), and no test could see + /// that at the time because nothing counted. This does. It is a + /// TEST-SIDE tally over the sealed batches, not a field on the AI: + /// counting inside the simulation would be state the AI could read. + /// + /// + public int IntentsSubmitted; + public void Step() { uint nextTick = Kernel.CurrentTick.Value + 1; CommandBatch batch = Ingress.SealTickBatch(nextTick); if (batch.Count > 0) { + IntentsSubmitted += batch.Count; Assert.That(Kernel.SubmitBatch(batch), Is.True, $"kernel refused the sealed batch of tick {nextTick}"); } @@ -151,7 +165,11 @@ private static AiProfile WavesOff() retreatDangerCells: shipped.RetreatDangerCells, // Waves off means waves off: the strength gate is the same // rule and switches off with them. - waveStrengthPoints: 0); + waveStrengthPoints: 0, + // And the defence with them. This profile exists to reproduce + // the behaviour from BEFORE the wave rules, and a defence that + // pulls gatherers home is a rule of the same generation. + defendHomeCells: 0); } private static AiHost BuildAiHost( @@ -528,7 +546,15 @@ public void AiBehaviorId_TracksWhichAiThisIs() // outcome unmoved is exactly the "declared change, no effect yet" // case — under the old coupled pin it was indistinguishable from a // simulation change. - Assert.That(AiBehaviorId.Value, Is.EqualTo("r7.E34435F9"), + // + // r8 moves BOTH halves at once, and that combination is itself the + // statement: the revision because decisions change (DefendHome + // breaks the gatherers off when the base is under attack), and the + // profile hash because the rule ships with the off switch that + // 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("r8.1E6E7AE3"), "the AI identifier changed — bump the revision and write the journal entry"); } @@ -829,7 +855,11 @@ public void SkirmishAi_LaunchesTheNextWave_WhenTheArmyCapCannotRefillIt() retreatDangerCells: shipped.RetreatDangerCells, // The COUNT path on purpose — this test pins the r5 rule, and // the strength path has its own test. - waveStrengthPoints: 0); + waveStrengthPoints: 0, + // Defence off for the same reason: this test is about a wave + // that must launch, and a rule that can hold units at home is + // a second explanation for a wave that does not. + defendHomeCells: 0); AiHost host = BuildMatch(Seed, probe); int ring = probe.StagingDistanceCells + probe.StagingToleranceCells; @@ -949,7 +979,11 @@ private static AiProfile StrengthGateProbe(int waveStrengthPoints) stagingToleranceCells: shipped.StagingToleranceCells, retreatHealthPercent: 0, retreatDangerCells: shipped.RetreatDangerCells, - waveStrengthPoints: waveStrengthPoints); + waveStrengthPoints: waveStrengthPoints, + // One variable per probe. The gate is what this measures; a + // defence that can pull gatherers home would be a second + // reason for a wave to launch later than it did. + defendHomeCells: 0); } /// diff --git a/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs index 84e4bd6..63ca454 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs @@ -443,10 +443,264 @@ public void TheArmyReportExplainsItsOwnWaveVerdict() "the shipped profile measures the wave in strength, and no such decision was reported"); } + // ================================================================ + // DefendHome (r8) — breaking off the gathering when the base burns + // + // The five checks VERTEIDIGUNG.md asks for, and the reason each of + // them exists is a way the rule could pass while being wrong. Test 2 + // in particular: without it, "breaks off" and "attacks early" are + // both green, and only one of them is the rule. + // ================================================================ + + /// + /// AN ARMED ENEMY AT THE BASE PUTS THE GATHERERS UNDER + /// , and their orders point home. + /// + /// Asserted over the ORDER THAT WENT OUT and not only over the reported + /// goal: a name in the recording that no unit acts on would be the + /// panel lying in a new place. + /// + /// + [Test] + public void AnArmedEnemyAtTheBaseTurnsTheGatherersIntoDefenders() + { + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = GatheringHost(AiProfiles.Ms1Canonical, observer, out int hqX, out int hqY); + + SpawnEnemyInfantry(host, hqX + 2, hqY); + int before = observer.Units.Count; + RunToNextDecision(host); + + int defenders = 0; + for (int i = before; i < observer.Units.Count; i++) + { + AiUnitGoal goal = observer.Units[i].Goal; + if (goal.Goal != GoalKind.DefendHome) continue; + defenders++; + Assert.That(goal.MoveCellX, Is.EqualTo(hqX), "a defender was not sent to the headquarters"); + Assert.That(goal.MoveCellY, Is.EqualTo(hqY), "a defender was not sent to the headquarters"); + Assert.That(goal.AttackTargetRaw, Is.Not.Zero, + "a defender walks home carrying no target — finding F001, it would fire at nothing"); + } + Assert.That(defenders, Is.GreaterThan(0), + "the headquarters is under attack and not one waiting unit broke off"); + + // And the standing order really is the one the goal names. + Assert.That(AnyCombatUnitOrderedTo(host, AiSlot, hqX, hqY), Is.True, + "no unit actually carries the march order the recording claims"); + } + + /// + /// THE WAVE IS INTERRUPTED, NOT RELEASED. No gatherer is sent toward + /// the army's target. + /// + /// WITHOUT THIS TEST BOTH BEHAVIOURS ARE GREEN. "Everyone marches out + /// early" also empties the staging ring and also ends with units + /// fighting, and it is the opposite of the rule: it takes the defenders + /// AWAY from the base that is being shot. + /// + /// + [Test] + public void TheDefenceDoesNotSendTheWaveOffEarly() + { + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = GatheringHost(AiProfiles.Ms1Canonical, observer, out int hqX, out int hqY); + + SpawnEnemyInfantry(host, hqX + 2, hqY); + int before = observer.Units.Count; + RunToNextDecision(host); + + for (int i = before; i < observer.Units.Count; i++) + { + AiUnitGoal goal = observer.Units[i].Goal; + if (goal.Goal != GoalKind.DefendHome) continue; + + // Every step toward the enemy start area is a step away from + // the fight at home, so the only acceptable destination is the + // headquarters itself. + Assert.That(Math.Max(Math.Abs(goal.MoveCellX - hqX), Math.Abs(goal.MoveCellY - hqY)), + Is.Zero, + $"unit {goal.EntityRaw} is under DefendHome and walking to " + + $"{goal.MoveCellX},{goal.MoveCellY} instead of home at {hqX},{hqY}"); + } + } + + /// + /// COMMITTED STAYS COMMITTED. A wave that is already out is not called + /// back — the r3 rule that made a wave a wave, and the V002 failure + /// mode if it fell through the back door. + /// + [Test] + public void AWaveThatIsAlreadyOutIsNotCalledBack() + { + AiProfile shipped = AiProfiles.Ms1Canonical; + int ring = shipped.StagingDistanceCells + shipped.StagingToleranceCells; + + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, goalObserver: observer); + Assert.That(TryHqCell(host, AiSlot, out int hqX, out int hqY), Is.True); + + int budget = SkirmishAiTests.EndToEndBudgetTicks; + while (budget-- > 0 && FarthestCombatDistance(host, AiSlot, hqX, hqY) <= ring) host.Step(); + Assert.That(FarthestCombatDistance(host, AiSlot, hqX, hqY), Is.GreaterThan(ring), + "the army never marched, so nothing could be called back"); + + SpawnEnemyInfantry(host, hqX + 2, hqY); + int before = observer.Units.Count; + RunToNextDecision(host); + + bool judgedSomebodyOutside = false; + for (int i = before; i < observer.Units.Count; i++) + { + AiUnitGoal goal = observer.Units[i].Goal; + if (goal.HomeDistanceCells <= ring) continue; + judgedSomebodyOutside = true; + Assert.That(goal.Goal, Is.Not.EqualTo(GoalKind.DefendHome), + $"unit {goal.EntityRaw} is {goal.HomeDistanceCells} cells out, past the ring at " + + $"{ring}, and the defence called it back"); + } + Assert.That(judgedSomebodyOutside, Is.True, "no unit was outside the ring when the enemy arrived"); + } + + /// + /// THE OFF SETTING IS OFF. With defendHomeCells: 0 the same scene + /// produces no defender at all. + /// + /// That the off path is bit-identical to r7 is a claim about two + /// BUILDS and cannot be asserted from inside one — it is measured in + /// the lab (`compare` against `defend-off`, and the hash chain of the + /// canonical match). What belongs here is the half that is checkable: + /// zero means the rule cannot fire, which is what makes the one-sided + /// measurement mean anything at all (finding M001). + /// + /// + [Test] + public void WithTheRuleOffNobodyDefends() + { + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = GatheringHost(DefenceOff(), observer, out int hqX, out int hqY); + + SpawnEnemyInfantry(host, hqX + 2, hqY); + int before = observer.Units.Count; + RunToNextDecision(host); + + for (int i = before; i < observer.Units.Count; i++) + { + Assert.That(observer.Units[i].Goal.Goal, Is.Not.EqualTo(GoalKind.DefendHome), + "defendHomeCells is 0 and a unit was still put under DefendHome"); + } + for (int i = before; i < observer.Army.Count; i++) + { + Assert.That(observer.Army[i].Goal.HomeThreatened, Is.False, + "defendHomeCells is 0 and the posture still reports the base as threatened"); + } + } + + /// + /// NO COMMAND STREAM — the test V002 did not have. + /// + /// An unchanged situation over several cadences must not produce a + /// second order. That is the whole reason the destination is the + /// headquarters, a cell that does not move: DefendBase aimed at + /// the enemy, handed every unit a fresh destination every cadence, and + /// died of 23 % more intents. The enemy here is deliberately left + /// standing so the trigger holds while the defenders arrive. + /// + /// + [Test] + public void AHeldDefenceDoesNotProduceAnOrderEveryCadence() + { + SkirmishAiTests.AiHost host = GatheringHost(AiProfiles.Ms1Canonical, null, out int hqX, out int hqY); + + SpawnEnemyInfantry(host, hqX + 2, hqY); + RunToNextDecision(host); // the decision that turns them around + + int cadence = host.Ai.DecisionTickInterval; + int afterTurn = host.IntentsSubmitted; + for (int i = 0; i < 5; i++) RunToNextDecision(host); + + int perCadence = (host.IntentsSubmitted - afterTurn) / 5; + Assert.That(perCadence, Is.LessThanOrEqualTo(2), + $"five cadences of an unchanged defence cost {host.IntentsSubmitted - afterTurn} intents " + + $"({perCadence} per cadence of {cadence} ticks) — a static destination must be suppressed " + + "after the first order, and this is the shape DefendBase died of (journal V002)"); + } + // ---------------------------------------------------------------- // Deterministic read helpers (ascending entity index) // ---------------------------------------------------------------- + /// The shipped profile with the defence switched off, and nothing else changed. + private static AiProfile DefenceOff() + { + AiProfile s = AiProfiles.Ms1Canonical; + return new AiProfile( + profileId: "defence-off-probe", + decisionTickInterval: s.DecisionTickInterval, + placementSearchRadius: s.PlacementSearchRadius, + powerReserve: s.PowerReserve, + targetHarvesters: s.TargetHarvesters, + harvesterQueueBatch: s.HarvesterQueueBatch, + targetArmySize: s.TargetArmySize, + attackSquadThreshold: s.AttackSquadThreshold, + infantryQueueBatch: s.InfantryQueueBatch, + targetDamageWeight: s.TargetDamageWeight, + targetThreatWeight: s.TargetThreatWeight, + targetFinishWeight: s.TargetFinishWeight, + targetDistanceWeight: s.TargetDistanceWeight, + waveSize: s.WaveSize, + stagingDistanceCells: s.StagingDistanceCells, + stagingToleranceCells: s.StagingToleranceCells, + retreatHealthPercent: s.RetreatHealthPercent, + retreatDangerCells: s.RetreatDangerCells, + waveStrengthPoints: s.WaveStrengthPoints, + defendHomeCells: 0); + } + + /// + /// A host run forward to the point where the army is GATHERING: it acts + /// (so the army step judges anybody at all) and its units are still + /// inside the staging ring (so there is something to break off). + /// + private static SkirmishAiTests.AiHost GatheringHost( + AiProfile profile, RecordingObserver observer, out int hqX, out int hqY) + { + int ring = profile.StagingDistanceCells + profile.StagingToleranceCells; + SkirmishAiTests.AiHost host = + SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, profile, goalObserver: observer); + + Assert.That(TryHqCell(host, AiSlot, out hqX, out hqY), Is.True); + + int budget = SkirmishAiTests.EndToEndBudgetTicks; + while (budget-- > 0 + && (CountCombatUnits(host, AiSlot) < profile.AttackSquadThreshold + || FarthestCombatDistance(host, AiSlot, hqX, hqY) > ring)) + { + host.Step(); + } + + Assert.That(CountCombatUnits(host, AiSlot), Is.GreaterThanOrEqualTo(profile.AttackSquadThreshold), + "the army never reached its squad threshold, so no goal is handed out at all"); + Assert.That(FarthestCombatDistance(host, AiSlot, hqX, hqY), Is.LessThanOrEqualTo(ring), + "no unit is gathering inside the ring, so there is nothing to break off"); + return host; + } + + /// Whether any combat unit of the seat carries a march order to this cell. + private static bool AnyCombatUnitOrderedTo( + SkirmishAiTests.AiHost host, byte slot, int cellX, 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 || !IsCombat(u.Role)) continue; + if (!u.TargetGridPos.IsValid) continue; + if (u.TargetGridPos.X == cellX && u.TargetGridPos.Y == cellY) return true; + } + return false; + } + private static bool TryHqCell(SkirmishAiTests.AiHost host, byte slot, out int cellX, out int cellY) { UnitState[] units = host.Entities.RawUnits; From abfc6f6590892296305a0fa62ae7b9fffe259d46 Mon Sep 17 00:00:00 2001 From: arn-c0de Date: Mon, 10 Aug 2026 21:14:33 +0200 Subject: [PATCH 4/5] fix(ai): Goal-Naehte nach Nova.AI, damit Unity wieder kompiliert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IAiGoalObserver und IAiGoalOverride lagen in Nova.AI.Data. Nova.Gameplay und Nova.AI.Tests referenzieren aber nur Nova.AI, und dass niemand die beiden optionalen Konstruktorargumente uebergibt, hilft nicht: der Compiler muss die Parametertypen fuer die Ueberladungsaufloesung laden. Ergebnis war CS0012 in MatchRunner.cs und in den Unity-EditMode-Tests, also eine rote Startkompilierung — und weil -executeMethod danach laeuft, die Meldung "class 'DemoLauncher' could not be found". Der Demo-Start war nie kaputt, die Assembly war es. dotnet test hat das nicht gesehen und kann es nicht sehen: SimRunner und Labor ziehen alle Quellordner in eine Assembly, die Grenzen gibt es nur in Unity. Die Naehte gehoeren ohnehin zur Vertragsflaeche der KI und nicht zur Datenschicht: sie ziehen jetzt in Nova.AI um, die jeder referenziert, der die KI baut. Damit braucht das ausgelieferte Spiel fuer den Konstruktor keine Referenz auf die KI-Datenassembly. Die Worte, auf die sich drei Oberflaechen einigen muessen — GoalKind, Profile, AiBehaviorId — bleiben in Nova.AI.Data, wie es dort dokumentiert ist. Die Alternative waere eine Zeile in Nova.Gameplay.asmdef gewesen. Das ist fremdes Terrain und haette eine Rueckfrage gebraucht, nicht einen Commit. Geprueft: dotnet test 721/721 gruen. In Unity noch nicht nachkompiliert. --- Assets/_Project/Scripts/AI.Data/GoalKind.cs | 2 +- Assets/_Project/Scripts/{AI.Data => AI}/IAiGoalObserver.cs | 4 +++- .../_Project/Scripts/{AI.Data => AI}/IAiGoalObserver.cs.meta | 0 Assets/_Project/Scripts/{AI.Data => AI}/IAiGoalOverride.cs | 4 +++- .../_Project/Scripts/{AI.Data => AI}/IAiGoalOverride.cs.meta | 0 tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs | 1 + 6 files changed, 8 insertions(+), 3 deletions(-) rename Assets/_Project/Scripts/{AI.Data => AI}/IAiGoalObserver.cs (99%) rename Assets/_Project/Scripts/{AI.Data => AI}/IAiGoalObserver.cs.meta (100%) rename Assets/_Project/Scripts/{AI.Data => AI}/IAiGoalOverride.cs (97%) rename Assets/_Project/Scripts/{AI.Data => AI}/IAiGoalOverride.cs.meta (100%) diff --git a/Assets/_Project/Scripts/AI.Data/GoalKind.cs b/Assets/_Project/Scripts/AI.Data/GoalKind.cs index f6dddfa..5517f83 100644 --- a/Assets/_Project/Scripts/AI.Data/GoalKind.cs +++ b/Assets/_Project/Scripts/AI.Data/GoalKind.cs @@ -53,7 +53,7 @@ public enum GoalKind : byte { /// /// No goal — the unit was not judged at all this decision, and for the - /// goal mask () it means "leave this one to + /// goal mask (Nova.AI.IAiGoalOverride) it means "leave this one to /// the AI". Never the answer of the resolver: every combat unit the army /// step looks at gets one of the four below. /// diff --git a/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs b/Assets/_Project/Scripts/AI/IAiGoalObserver.cs similarity index 99% rename from Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs rename to Assets/_Project/Scripts/AI/IAiGoalObserver.cs index d6169af..fb50485 100644 --- a/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs +++ b/Assets/_Project/Scripts/AI/IAiGoalObserver.cs @@ -1,4 +1,6 @@ -namespace Nova.AI.Data +using Nova.AI.Data; + +namespace Nova.AI { /// Which rule the wave gate answered with this decision. public enum WaveGateMode : byte diff --git a/Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs.meta b/Assets/_Project/Scripts/AI/IAiGoalObserver.cs.meta similarity index 100% rename from Assets/_Project/Scripts/AI.Data/IAiGoalObserver.cs.meta rename to Assets/_Project/Scripts/AI/IAiGoalObserver.cs.meta diff --git a/Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs b/Assets/_Project/Scripts/AI/IAiGoalOverride.cs similarity index 97% rename from Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs rename to Assets/_Project/Scripts/AI/IAiGoalOverride.cs index c904599..33ac884 100644 --- a/Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs +++ b/Assets/_Project/Scripts/AI/IAiGoalOverride.cs @@ -1,4 +1,6 @@ -namespace Nova.AI.Data +using Nova.AI.Data; + +namespace Nova.AI { /// /// A goal forced onto single units from outside — the "override" half of the diff --git a/Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs.meta b/Assets/_Project/Scripts/AI/IAiGoalOverride.cs.meta similarity index 100% rename from Assets/_Project/Scripts/AI.Data/IAiGoalOverride.cs.meta rename to Assets/_Project/Scripts/AI/IAiGoalOverride.cs.meta diff --git a/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs index 63ca454..a701e30 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using NUnit.Framework; +using Nova.AI; using Nova.AI.Data; using Nova.Core; using Nova.Simulation.Definitions; From d7a51390268e58063e7453465dbb7291c7f76621 Mon Sep 17 00:00:00 2001 From: arn-c0de Date: Mon, 10 Aug 2026 21:24:49 +0200 Subject: [PATCH 5/5] fix(ai): zwei Korrekturen an DefendHome vor der Auslieferung Beide beim Pruefen der Begruendung gefunden, nicht des Codes. Keine fuegt eine Zahl hinzu, also bewegt sich ProfileHash nicht und der Aus-Pfad (defendHomeCells: 0) bleibt bitgenau r7. 1. Die statische Zielzelle allein traegt das Argument nicht. Die Re-Issue-Unterdrueckung vergleicht den stehenden Befehl, und MovementSystem loescht genau den bei der Ankunft (UnitState.Stop()). Ein angekommener Verteidiger hatte damit nichts mehr zu vergleichen: die HQ-Zelle ging jede Kadenz erneut raus. Gemessen ein Move-Intent pro Kadenz fuer die Dauer der Belagerung, mit acht stehenden Einheiten, die jedes Mal wieder in IsMoving kippten -- die V002-Form eine Nummer kleiner. DefendHome verstummt jetzt selbst, sobald die Einheit daheim steht; dieselbe Stille, die Hold am Sammelpunkt hat. 2. Die Regel fragte, ob die Einheit gerade zurueckzieht. Diese Klausel konnte nur Verwundete treffen, die schon angekommen waren -- Laufende nimmt Retreat eine Zeile frueher. Ankommen beendet den Rueckzug aber nach der eigenen Regel der KI. Sie hat also nichts bewirkt ausser genau diese Einheiten an Hold zu geben: zwoelf Zellen draussen stehend, auf einen Verfolger zielend, den sie nicht erreichen, waehrend die Basis brennt, zu der sie zurueckgelaufen waren. Dazu zwei kleinere Sachen im selben Zug: CollectVisibleThreats laesst eine Entity-Id, die das Wire-Format nicht ausdruecken kann, jetzt aus (eine 0 gewaenne den Tie-Break auf die niedrigste Id und hiesse dann stillschweigend "kein Angriffsbefehl"), und der Goal-Katalog zaehlt an vier Stellen wieder richtig -- r8 hatte ein fuenftes Goal eingezogen und "die vier Bedingungen" stehen lassen. Zwei neue Tests, beide gegen den unkorrigierten Stand als rot nachgewiesen: AnArrivedDefenderIsNotSentHomeAgain und AWoundedUnitThatIsAlreadyHomeDefendsInsteadOfHolding. Der bestehende AHeldDefenceDoesNotProduceAnOrderEveryCadence konnte Punkt 1 nicht sehen -- alle Verteidiger teilen eine Zielzelle, die Wiederholung kostet also genau einen gruppierten Intent und liegt unter seiner Schranke. Der CHANGELOG-Eintrag sagt jetzt ausdruecklich, dass die Wirkungszahlen (96 % auf 60 %, 3.213 auf 6.490 Ticks, 18 auf 60 Verluste) am unkorrigierten DefendHome gemessen wurden und vor dem Merge neu zu messen sind. Ebenso notiert, nicht behoben: unterhalb der Squad-Schwelle laeuft der Armeeschritt gar nicht, also verteidigt niemand -- das ist eine Verhaltensausweitung mit eigener Messpflicht und gehoert in einen eigenen PR. Geprueft: dotnet test 722/722 gruen. Im laufenden Spiel nicht gesehen. --- .../_Project/Scripts/AI.Data/AiBehaviorId.cs | 16 ++ Assets/_Project/Scripts/AI.Data/AiProfile.cs | 21 +- Assets/_Project/Scripts/AI.Data/AiProfiles.cs | 11 +- Assets/_Project/Scripts/AI.Data/GoalKind.cs | 20 +- .../_Project/Scripts/AI/SkirmishAiSystem.cs | 116 +++++++++-- CHANGELOG.md | 78 ++++++-- .../Nova.SimRunner.Tests/SkirmishGoalTests.cs | 188 +++++++++++++++++- 7 files changed, 387 insertions(+), 63 deletions(-) diff --git a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs index a215c0f..18d9904 100644 --- a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs +++ b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs @@ -88,6 +88,22 @@ public static class AiBehaviorId /// or seven cells while the staging cell sits twelve from the base. /// /// + /// TWO CORRECTIONS WENT INTO r8 BEFORE IT SHIPPED, both found by + /// reviewing the claim rather than the code, and neither moves + /// because neither adds a number. First: the + /// argument "the destination is static, so the suppression swallows + /// every repeat" only holds WHILE the defender walks — arriving clears + /// the standing order the suppression compares against, so the + /// headquarters cell went out again every cadence, one intent per + /// cadence for the whole siege, with the standing defenders flipped back + /// into movement each time. DefendHome now falls silent once + /// home, the way Hold does at the staging cell. Second: the goal + /// asked not to be retreating, which could only ever exclude a wounded + /// unit that had ALREADY ARRIVED — and arriving ends the retreat by the + /// rule right above it. Those units fell to Hold and stood twelve + /// cells out while the base they had run to burned. + /// + /// /// r5 fixes two defects found in the review of r3/r4, both of which /// change decisions and therefore the end state: the wave now waits for /// what production can still deliver instead of a fixed cap (a single diff --git a/Assets/_Project/Scripts/AI.Data/AiProfile.cs b/Assets/_Project/Scripts/AI.Data/AiProfile.cs index e30e882..68752d3 100644 --- a/Assets/_Project/Scripts/AI.Data/AiProfile.cs +++ b/Assets/_Project/Scripts/AI.Data/AiProfile.cs @@ -203,12 +203,21 @@ namespace Nova.AI.Data /// /// THERE IS NO SECOND RADIUS. A hysteresis value — "stay home until the /// enemy is fourteen cells out" — is the obvious second number and is - /// deliberately absent: both destinations are STATIC cells and the - /// re-issue suppression already swallows an unchanged order, so whether - /// the trigger flutters is a question for the intent column and not for - /// a precaution. That is the correction over the discarded - /// DefendBase (journal V002), which handed the WHOLE army a new - /// destination every cadence and paid 23 % more intents for it. + /// deliberately absent: both destinations are STATIC cells, a defender + /// that has arrived stops being ordered at all, and the re-issue + /// suppression swallows the rest, so whether the trigger flutters is a + /// question for the intent column and not for a precaution. That is the + /// correction over the discarded DefendBase (journal V002), which + /// handed the WHOLE army a new destination every cadence and paid 23 % + /// more intents for it. + /// + /// + /// THE STATIC CELL IS NOT ENOUGH ON ITS OWN, and the first version of + /// this rule assumed it was. The suppression compares the STANDING + /// order, and arriving clears it — so the silence a defender needs once + /// it is home is written out in SkirmishAiSystem.HasArrivedAtHome + /// rather than falling out of the geometry. Measured before that + /// existed: one move intent per cadence for as long as the siege ran. /// /// public int DefendHomeCells { get; } diff --git a/Assets/_Project/Scripts/AI.Data/AiProfiles.cs b/Assets/_Project/Scripts/AI.Data/AiProfiles.cs index d17cc81..f1e9156 100644 --- a/Assets/_Project/Scripts/AI.Data/AiProfiles.cs +++ b/Assets/_Project/Scripts/AI.Data/AiProfiles.cs @@ -157,10 +157,13 @@ public static class AiProfiles // // The wave is INTERRUPTED, not released: the destination is the own // headquarters, a static cell, so the defenders walk toward the - // fight rather than away from it and the re-issue suppression - // swallows every repeat. That is the correction over the discarded - // DefendBase (V002), which handed the whole army a moving target - // every cadence and paid 23 % more intents for it. + // fight rather than away from it, the re-issue suppression swallows + // the repeats on the way, and a defender that has arrived is not + // ordered any more at all (the suppression alone cannot carry that + // — arriving clears the standing order it compares against). That is + // the correction over the discarded DefendBase (V002), which handed + // the whole army a moving target every cadence and paid 23 % more + // intents for it. // // 0 switches the rule off and restores r7 exactly (M001). defendHomeCells: 10); diff --git a/Assets/_Project/Scripts/AI.Data/GoalKind.cs b/Assets/_Project/Scripts/AI.Data/GoalKind.cs index 5517f83..8d1fad9 100644 --- a/Assets/_Project/Scripts/AI.Data/GoalKind.cs +++ b/Assets/_Project/Scripts/AI.Data/GoalKind.cs @@ -23,8 +23,9 @@ namespace Nova.AI.Data /// written in SkirmishAiSystem.ResolveGoal, which is: /// /// - /// — a wounded unit outranks everything, or the - /// pull-back could never reach the one unit it exists for + /// — a wounded unit STILL RUNNING outranks + /// everything, or the pull-back could never reach the one unit it exists + /// for; one that has arrived is an ordinary waiting unit again /// — the base burning outranks gathering for /// a wave that will leave it burning /// @@ -55,7 +56,7 @@ public enum GoalKind : byte /// No goal — the unit was not judged at all this decision, and for the /// goal mask (Nova.AI.IAiGoalOverride) it means "leave this one to /// the AI". Never the answer of the resolver: every combat unit the army - /// step looks at gets one of the four below. + /// step looks at gets one of the five below. /// None = 0, @@ -95,7 +96,18 @@ public enum GoalKind : byte /// Outranks — gathering for a wave that marches /// away from a burning base is the defect this goal exists for — and /// gives way to , because a unit too wounded to - /// fight is no defender. + /// fight and still running is no defender. One that has already made it + /// home is: arriving ends the retreat, so it defends like anybody else + /// standing in the ring. + /// + /// AND ONCE HOME IT SAYS NOTHING FURTHER. The destination is a static + /// cell, but that alone does not stop the order repeating — the re-issue + /// suppression reads the standing order, and arriving CLEARS the + /// standing order. A defender that has arrived therefore falls silent + /// explicitly, exactly the way is silent at the + /// staging cell; without it the headquarters cell went out again every + /// cadence for the whole siege. + /// /// /// ONLY UNITS STILL INSIDE THE STAGING RING. A wave that is already out /// keeps marching: "units that are out are never called back" is the r3 diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs index bea4aa8..67349bf 100644 --- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs +++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs @@ -71,8 +71,9 @@ namespace Nova.AI /// as funds allow; /// (5) the army resolves a POSTURE (does it act at all, which target, /// which destination), then ONE GOAL PER UNIT out of the fixed priority - /// list in Retreat, Attack, - /// Hold, Advance — whose effect is the unit's orders, then + /// list in Retreat, DefendHome, + /// Attack, Hold, Advance — whose effect is the unit's + /// orders, then /// submission that groups units sharing an order into a single intent: at /// living combat units /// the army is sent toward the enemy start area, and the best scored @@ -978,10 +979,19 @@ private void CollectVisibleThreats(List cells, List raws) if (_construction.IsActiveSite(u.Id)) continue; if (WeaponProfiles.Get(_economy.GetSlotFaction(u.PlayerId), u.Role).AttackDamage <= 0) continue; + // A handle the wire format cannot express is dropped here, the + // same way the own scan drops it. Zero is not a spare value in + // this list: it wins the tie-break on the lowest raw id in + // NearestThreatRaw, and an assignment carrying it submits NO + // attack intent — the threat would silence the pursuer instead + // of aiming it. + uint raw = UnitCommandStateView.ToRawEntityId(u.Id); + if (raw == 0) continue; + long x = GridCellOf(u.Transform.PositionX); long y = GridCellOf(u.Transform.PositionY); cells.Add((y << 32) | x); - raws.Add(UnitCommandStateView.ToRawEntityId(u.Id)); + raws.Add(raw); } } @@ -1148,7 +1158,14 @@ private UnitAssignment ResolveUnitAssignment( ? NearestThreatRaw(in unit, threatCells, threatRaws) : 0u; - UnitAssignment assignment = ApplyGoal(goal, entityRaw, in posture, pursuerRaw); + // Whether the defender is already standing at the base — the second + // caller-computed fact the table reads, for the same reason the + // pursuer is one: a defender that has arrived must fall silent, or + // the headquarters cell goes out again every cadence. See + // HasArrivedAtHome. + bool atHome = HasArrivedAtHome(in unit, in posture); + + UnitAssignment assignment = ApplyGoal(goal, entityRaw, in posture, pursuerRaw, atHome); if (_goalObserver != null) { @@ -1159,10 +1176,10 @@ private UnitAssignment ResolveUnitAssignment( } /// - /// WHICH GOAL THIS UNIT IS UNDER — the four conditions, in priority - /// order, exactly as numbers them. + /// WHICH GOAL THIS UNIT IS UNDER — the five conditions, in priority + /// order, exactly as lists them. /// - /// TWO CLAUSES ARE NOT OBVIOUS AND BOTH ARE LOAD-BEARING. + /// THREE CLAUSES ARE NOT OBVIOUS AND ALL THREE ARE LOAD-BEARING. /// /// /// Retreat steps aside for a unit that has arrived. @@ -1186,6 +1203,16 @@ private UnitAssignment ResolveUnitAssignment( /// units still INSIDE the ring: a wave that is already out keeps going, /// which is the r3 rule that made a wave a wave and the V002 failure /// mode if it were dropped. + /// It does NOT ask whether the unit is wounded. It cannot, + /// and the first version that did was wrong: Retreat has already + /// returned above for every unit still running, so the only wounded unit + /// that reaches this line is one that HAS ARRIVED — and arriving ends + /// the retreat by the rule two clauses up. Asking again handed exactly + /// those units to Hold, so under siege the ones who were already + /// home stood twelve cells out at the staging cell, aiming at a pursuer + /// they could not reach, while the base they had run back to burned. + /// A unit that is home enough to leave with the next wave is home enough + /// to defend. /// /// private GoalKind ResolveGoal( @@ -1198,7 +1225,7 @@ private GoalKind ResolveGoal( // unit is still gathering, and the march test needs the same fact. bool committed = IsCommittedToTheWave(in unit, hqCellX, hqCellY); - if (!retreats && posture.HomeThreatened && !committed) return GoalKind.DefendHome; + if (posture.HomeThreatened && !committed) return GoalKind.DefendHome; if (!retreats && IsFitToMarch(in posture, committed)) return GoalKind.Attack; if (arrived) return GoalKind.Hold; return GoalKind.Advance; @@ -1233,11 +1260,50 @@ private bool HasArrivedAtTheStagingCell(in UnitState unit, in ArmyPosture postur } /// - /// THE EFFECT OF A GOAL — one table, four rows, no conditions. + /// True when a defender is HOME and standing — within + /// of the headquarters and + /// not walking. falls silent for it, + /// the way is silent at the staging cell. + /// + /// WITHOUT THIS THE STATIC DESTINATION DOES NOT HELP, and the argument + /// the rule was built on is only half true. The re-issue suppression in + /// compares the STANDING ORDER + /// (UnitState.TargetGridPos) — and MovementSystem calls + /// UnitState.Stop() on arrival, which invalidates exactly that + /// field. So a defender that has arrived has nothing left to compare + /// against, the suppression stops recognising the repeat, and the + /// headquarters cell goes out again every single cadence: measured, one + /// move intent per cadence for as long as the siege lasts, with eight + /// standing units flipped back into IsMoving each time. Static + /// destinations survive the suppression only WHILE THEY ARE BEING + /// WALKED TO; standing still needs the same silence Hold has. + /// + /// + /// The tolerance is the staging one and deliberately not a new profile + /// value: it is the same phenomenon (a group arriving spreads over + /// several cells, and the headquarters footprint is impassable, so ring + /// 0 is never claimed), and a new number would move + /// AiProfile.ProfileHash for a correction that changes no rule. + /// Four cells around the headquarters cannot collide with the staging + /// cell either — that one sits twelve cells out. + /// + /// + private bool HasArrivedAtHome(in UnitState unit, in ArmyPosture posture) + { + if (unit.IsMoving) return false; + int distance = Chebyshev( + GridCellOf(unit.Transform.PositionX), GridCellOf(unit.Transform.PositionY), + posture.HomeCellX, posture.HomeCellY); + return distance <= _profile.Profile.StagingToleranceCells; + } + + /// + /// THE EFFECT OF A GOAL — one table, five rows, no conditions. /// - /// Every row is a pure function of the goal, the posture and the pursuer - /// the caller worked out, which is what makes a forced goal safe: a - /// panel that names Retreat for a healthy unit gets the orders + /// Every row is a pure function of the goal, the posture and the two + /// facts the caller worked out (the pursuer, and whether a defender is + /// already home), which is what makes a forced goal safe: a panel that + /// names Retreat for a healthy unit gets the orders /// Retreat always produces, not a state the AI has no code for. /// /// @@ -1250,7 +1316,7 @@ private bool HasArrivedAtTheStagingCell(in UnitState unit, in ArmyPosture postur /// /// private static UnitAssignment ApplyGoal( - GoalKind goal, uint entityRaw, in ArmyPosture posture, uint pursuerRaw) + GoalKind goal, uint entityRaw, in ArmyPosture posture, uint pursuerRaw, bool atHome) { switch (goal) { @@ -1285,17 +1351,27 @@ private static UnitAssignment ApplyGoal( // The same shape Retreat has, aimed at the other static // cell: walk to the headquarters, shoot the nearest armed // enemy on the way. Static is the operative word — the - // headquarters does not move all match, so the re-issue - // suppression in SubmitAssignments swallows every repeat - // and a defence that lasts twenty cadences costs one order. - // DefendBase aimed at the ENEMY, which moves, and paid a - // fresh order per unit per cadence for it (journal V002). + // headquarters does not move all match, so while a defender + // is WALKING the re-issue suppression in SubmitAssignments + // swallows every repeat. DefendBase aimed at the ENEMY, + // which moves, and paid a fresh order per unit per cadence + // for it (journal V002). + // + // ONCE HOME IT FALLS SILENT, and that is not a nicety. The + // suppression compares the standing order, MovementSystem + // clears the standing order on arrival, and a defender that + // has arrived would therefore be sent home AGAIN every + // cadence — measured before this row existed: one move + // intent per cadence for the whole siege and eight standing + // units flipped back into IsMoving each time, which is the + // V002 shape at a smaller size. Hold's silence, for the + // other static cell. See HasArrivedAtHome. return new UnitAssignment { EntityRaw = entityRaw, AttackTargetRaw = pursuerRaw, - MoveCellX = posture.HomeCellX, - MoveCellY = posture.HomeCellY, + MoveCellX = atHome ? -1 : posture.HomeCellX, + MoveCellY = atHome ? -1 : posture.HomeCellY, }; default: diff --git a/CHANGELOG.md b/CHANGELOG.md index cc550b5..7c2ab4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,33 +99,67 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de Einheiten im Median **13 Zellen** entfernt unter `Hold` stehen und **keine einzige** angreift. Der Defekt ist so alt wie der Sammelpunkt (`r3`). Neues Goal `DefendHome` mit dem Profilfeld `defendHomeCells` (ausgeliefert - **10**, **0 = aus**): wer noch **im Sammelring** steht und nicht gerade - zurückzieht, marschiert zum eigenen HQ und zielt auf den nächsten - sichtbaren bewaffneten Gegner. Wer **draussen** ist, marschiert weiter — - die `r3`-Regel „Einheiten draussen werden nie zurückgerufen" bleibt, und - die Welle wird **unterbrochen, nicht freigegeben**. Das Ziel ist die - **statische** HQ-Zelle und ausdrücklich nicht der Gegner: genau daran ist - `DefendBase` gescheitert (+23 % Intents, schlechteres Spiel), weil ein - bewegliches Ziel jede Kadenz einen neuen Befehl für jede Einheit erzeugt. - Gemessen kostet die Regel **31 Beurteilungen 25 Befehle** auf **eine** - Zielzelle, und bei bedrohter Basis steht **keine** Einheit im Ring mehr - unter `Hold`. - - **Was die Regel bringt und was sie kostet.** Die Wehrlosigkeit im - Beschussfenster fällt von **96 % auf 60 %**, die Partie der Legion dauert - **3.213 → 6.490** Ticks. **Sie gewinnt sie trotzdem nicht**, und die eigenen - Verluste steigen von 18 auf 60 — die Regel verschiebt die Niederlage, sie - wendet sie nicht ab. Der Verhaltensbezeichner bewegt beide Hälften: - die Revision, weil Entscheidungen sich ändern, und `ProfileHash`, weil die - Regel mit ihrer Aus-Stellung ausgeliefert wird. `defendHomeCells: 0` auf - beiden Sitzen ergibt **bitgenau** die Partie von `r7` (Tick 3.213, - `0xE002DD893916967B`). + **10**, **0 = aus**): wer noch **im Sammelring** steht, marschiert zum + eigenen HQ und zielt auf den nächsten sichtbaren bewaffneten Gegner. Wer + **draussen** ist, marschiert weiter — die `r3`-Regel „Einheiten draussen + werden nie zurückgerufen" bleibt, und die Welle wird **unterbrochen, nicht + freigegeben**. Das Ziel ist die **statische** HQ-Zelle und ausdrücklich + nicht der Gegner: genau daran ist `DefendBase` gescheitert (+23 % Intents, + schlechteres Spiel), weil ein bewegliches Ziel jede Kadenz einen neuen + Befehl für jede Einheit erzeugt. + + **Zwei Korrekturen an `DefendHome`, bevor es ausgeliefert wird.** Beide + gefunden beim Prüfen der Begründung, nicht des Codes; beide bewegen + `ProfileHash` nicht, weil keine eine Zahl hinzufügt. + + 1. **Die statische Zielzelle allein trägt das Argument nicht.** Die + Re-Issue-Unterdrückung vergleicht den **stehenden** Befehl, und + `MovementSystem` **löscht** genau den bei der Ankunft + (`UnitState.Stop()`). Ein Verteidiger, der angekommen ist, hatte damit + nichts mehr zu vergleichen — die HQ-Zelle ging **jede Kadenz erneut + raus**, gemessen ein Move-Intent pro Kadenz für die Dauer der + Belagerung, mit acht stehenden Einheiten, die jedes Mal wieder in + `IsMoving` kippten. Das ist die `V002`-Form eine Nummer kleiner. + `DefendHome` schweigt jetzt selbst, sobald die Einheit daheim steht — + dieselbe Stille, die `Hold` am Sammelpunkt hat. + 2. **Die Regel fragte, ob die Einheit gerade zurückzieht.** Diese Klausel + konnte nur Verwundete treffen, die **schon angekommen** waren — + Laufende nimmt `Retreat` eine Zeile früher. Ankommen **beendet** den + Rückzug aber nach der eigenen Regel der KI. Die Klausel hat also nichts + bewirkt ausser genau diese Einheiten an `Hold` zu geben: zwölf Zellen + draussen stehend, auf einen Verfolger zielend, den sie nicht erreichen, + während die Basis brennt, zu der sie zurückgelaufen waren. + + **Was die Regel bringt und was sie kostet.** Der Verhaltensbezeichner bewegt + beide Hälften: die Revision, weil Entscheidungen sich ändern, und + `ProfileHash`, weil die Regel mit ihrer Aus-Stellung ausgeliefert wird. + `defendHomeCells: 0` auf beiden Sitzen ergibt **bitgenau** die Partie von + `r7` (Tick 3.213, `0xE002DD893916967B`) — der Aus-Pfad ist von den beiden + Korrekturen nicht berührt. + + **Die Wirkungszahlen sind noch die von vor den Korrekturen und gelten + deshalb nicht mehr.** Gemessen wurde am unkorrigierten `DefendHome`: + Wehrlosigkeit im Beschussfenster **96 % → 60 %**, Partiedauer der Legion + **3.213 → 6.490** Ticks, eigene Verluste **18 → 60**, und sie gewinnt die + Partie trotzdem nicht. Beide Korrekturen ändern ausgegebene Befehle und + damit den Verlauf; die Zahlen werden **vor dem Merge neu gemessen** und + hier ersetzt. Sie stehen hier als das, was sie sind — die Grössenordnung + eines Vorläufers, kein Nachweis für den ausgelieferten Stand. + + **Bekannte Lücke, nicht behoben:** unterhalb der Squad-Schwelle + (`attackSquadThreshold`, ausgeliefert 6) läuft der Armeeschritt gar nicht, + also verteidigt **niemand** — genau in dem Fenster, in dem die Basis am + schwächsten ist. Der Armeebericht meldet dabei `HomeThreatened: true`, + während nichts geschieht. Das zu ändern heisst, Einheiten unterhalb der + Schwelle überhaupt zu beurteilen, und das ist eine Verhaltensausweitung mit + eigener Messpflicht — sie gehört in einen eigenen PR, nicht in diesen. **Am Player angesehen:** die Regel ist in der Laboraufnahme `Nova.AiLab-goal-base-defense-r8-20260810` in Bewegung zu sehen — die Wartenden lösen sich vom Sammelpunkt und marschieren zum eigenen HQ. Das ist die Gegenaufnahme zu der, in der genau diese Einheiten weitersammeln, während - ihr Hauptquartier fällt. + ihr Hauptquartier fällt. **Die Aufnahme zeigt den Stand vor den beiden + Korrekturen oben.** **Im laufenden Spiel gesehen: nein.** Eine Aufzeichnung des Labors ist keine gespielte Partie; alles oben ist gemessen, nicht gespielt. - **16.7/C1: Fünf endliche Aetheriumfelder schaffen Knappheit (D-102)** — die diff --git a/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs index a701e30..53ef408 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs @@ -115,13 +115,14 @@ public FixedMask(GoalKind goal) // ---------------------------------------------------------------- /// - /// Every judged unit is under exactly one of the four goals, and the + /// Every judged unit is under exactly one of the five goals, and the /// three the canonical match has to contain all show up. /// - /// Retreat is NOT among them and cannot be: the opponent of this - /// match is passive and owns no armed unit, so no threat is ever visible - /// and nothing ever turns back. It has its own test below with the - /// wound written in — the same reason + /// Retreat and DefendHome are NOT among them and cannot + /// be: the opponent of this match is passive and owns no armed unit, so + /// no threat is ever visible — nothing turns back and nothing comes + /// home. Both have their own tests below with a threat spawned in — the + /// same reason /// SkirmishAi_PullsWoundedUnitsBackTowardTheirOwnBase exists /// beside the pinned end-to-end run. /// @@ -627,6 +628,165 @@ public void AHeldDefenceDoesNotProduceAnOrderEveryCadence() "after the first order, and this is the shape DefendBase died of (journal V002)"); } + /// + /// A DEFENDER THAT HAS ARRIVED IS NOT SENT HOME AGAIN. Over ten cadences + /// of an unchanged siege, a unit standing at the base with no march + /// order still has none afterwards. + /// + /// THE INTENT COUNT COULD NOT SEE THIS, which is why the test beside it + /// was not enough. Every defender shares one destination, so the repeat + /// costs ONE grouped intent per cadence — inside the tolerance the count + /// test allows, and invisible next to the economy's own traffic. The + /// defect is only visible on the unit: MovementSystem calls + /// UnitState.Stop() on arrival, which clears the very field the + /// re-issue suppression compares, so "the destination is static" stops + /// protecting anything the moment somebody gets there. Measured before + /// the fix: eight standing units re-ordered every cadence, for as long + /// as the siege lasted. + /// + /// + /// Asserted on the STANDING ORDER and not on a goal report: the goal is + /// still DefendHome either way — a defender at the base IS + /// defending — and what changed is the order it produces. + /// + /// + [Test] + public void AnArrivedDefenderIsNotSentHomeAgain() + { + AiProfile shipped = AiProfiles.Ms1Canonical; + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = GatheringHost(shipped, observer, out int hqX, out int hqY); + + // The trigger has to survive TWO cadences — one to turn the army + // around, one to judge the subject — and twelve defenders kill a + // shipped-health intruder inside the first. + SpawnEnemyInfantry(host, hqX + 2, hqY, maxHealth: 100_000_000); + RunToNextDecision(host); // the defence fires and the gatherers turn around + + // The subject is now PUT where a defender is two cadences later: + // at the headquarters, stopped. Placed rather than walked there on + // purpose — a real siege kills units, the army drops under its + // squad threshold, the whole army step stops running, and then + // everything stands still for a reason that has nothing to do with + // this rule. That scene passes while the defect is fully present. + // + // It is placed AFTER a decision has landed, not before: an intent + // sealed at the previous cadence arrives a tick later and would put + // the standing order straight back. + Assert.That(TryFirstCombatUnit(host, AiSlot, out EntityId subjectId, out _, out _), Is.True); + ref UnitState parked = ref host.Entities.GetUnitRef(subjectId); + parked.Transform = new Transform2D(SimFixed.FromInt(hqX), SimFixed.FromInt(hqY)); + parked.Stop(); + + uint subjectRaw = UnitCommandStateView.ToRawEntityId(subjectId); + int before = observer.Units.Count; + RunToNextDecision(host); + + bool seen = false; + for (int i = before; i < observer.Units.Count; i++) + { + AiUnitGoal goal = observer.Units[i].Goal; + if (goal.EntityRaw != subjectRaw) continue; + seen = true; + + Assert.That(goal.Goal, Is.EqualTo(GoalKind.DefendHome), + "the subject stands at a headquarters under attack and is not defending it"); + Assert.That(goal.MoveCellX, Is.LessThan(0), + "a defender that is already home was sent home AGAIN. The march order is suppressed " + + "by comparing UnitState.TargetGridPos, and MovementSystem CLEARS that field on " + + "arrival — so 'the destination is static' stops protecting anything the moment " + + "somebody gets there, and the headquarters cell goes out every cadence for the " + + "whole siege (journal V002 is this shape, one size down). DefendHome has to fall " + + "silent itself, the way Hold does at the staging cell"); + Assert.That(goal.AttackTargetRaw, Is.Not.Zero, + "silence about WALKING must not become silence about SHOOTING — a defender at the " + + "base still needs a target"); + break; + } + Assert.That(seen, Is.True, "the subject was never judged"); + + // And the silence is about being home, not about the goal being + // inert: the gatherers twelve cells out are still ordered in. + Assert.That(AnyCombatUnitOrderedTo(host, AiSlot, hqX, hqY), Is.True, + "no unit at all was ordered home, so the scene never exercised the rule"); + + // Finally the world, not the report: nothing was submitted for it. + Assert.That(host.Entities.TryGetUnit(subjectId, out UnitState after), Is.True); + Assert.That(after.TargetGridPos.IsValid, Is.False, + "the subject carries a march order again, so an intent went out for a unit that was " + + "already standing where it was being sent"); + } + + /// + /// A WOUNDED UNIT THAT IS ALREADY HOME DEFENDS LIKE ANYBODY ELSE. + /// + /// The rule used to ask not to be retreating, and that clause could only + /// ever catch a unit that had ARRIVED — one still running is taken by + /// Retreat one line earlier. But arriving ENDS the retreat by the + /// AI's own rule (MS-1 units never heal, so a unit that stayed under + /// Retreat until it recovered would occupy the army cap forever). + /// So the clause did nothing except hand the units who were already back + /// at the gathering point to Hold: standing twelve cells out, + /// aiming at a pursuer they could not reach, while the base they had run + /// to was being shot. + /// + /// + [Test] + public void AWoundedUnitThatIsAlreadyHomeDefendsInsteadOfHolding() + { + AiProfile shipped = AiProfiles.Ms1Canonical; + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = GatheringHost(shipped, observer, out int hqX, out int hqY); + + Assert.That(observer.Army.Count, Is.GreaterThan(0), "the army never reported a posture"); + AiArmyGoal army = observer.Army[observer.Army.Count - 1].Goal; + Assert.That(army.StagingCellX, Is.GreaterThanOrEqualTo(0), "no staging cell was resolved"); + + // The subject: parked AT the staging cell and standing, so it counts + // as arrived and its retreat is over. + Assert.That(TryFirstCombatUnit(host, AiSlot, out EntityId subjectId, out _, out _), Is.True); + ref UnitState subject = ref host.Entities.GetUnitRef(subjectId); + subject.Transform = new Transform2D( + SimFixed.FromInt(army.StagingCellX), SimFixed.FromInt(army.StagingCellY)); + subject.Stop(); + subject.CurrentHealth = subject.MaxHealth * (shipped.RetreatHealthPercent - 20) / 100; + + // One enemy that satisfies BOTH halves at once: inside the defence + // radius of the headquarters, and inside the danger radius of the + // subject — which is what makes it a wounded unit that is home AND + // under the retreat rule, the only case the dropped clause touched. + int enemyX = (hqX + army.StagingCellX) / 2; + int enemyY = (hqY + army.StagingCellY) / 2; + Assert.That(Math.Max(Math.Abs(enemyX - hqX), Math.Abs(enemyY - hqY)), + Is.LessThanOrEqualTo(shipped.DefendHomeCells), "the enemy does not threaten the base"); + Assert.That( + Math.Max(Math.Abs(enemyX - army.StagingCellX), Math.Abs(enemyY - army.StagingCellY)), + Is.LessThanOrEqualTo(shipped.RetreatDangerCells), "the enemy does not endanger the subject"); + SpawnEnemyInfantry(host, enemyX, enemyY, maxHealth: 100_000_000); + + uint subjectRaw = UnitCommandStateView.ToRawEntityId(subjectId); + int before = observer.Units.Count; + RunToNextDecision(host); + + bool seen = false; + for (int i = before; i < observer.Units.Count; i++) + { + AiUnitGoal goal = observer.Units[i].Goal; + if (goal.EntityRaw != subjectRaw) continue; + seen = true; + + Assert.That(goal.Goal, Is.EqualTo(GoalKind.DefendHome), + $"a wounded unit that is already home was reported as {goal.Goal} while the base is " + + "under attack — arriving ends the retreat, so it is an ordinary defender"); + Assert.That(goal.MoveCellX, Is.EqualTo(hqX), "the defender was not sent to the headquarters"); + Assert.That(goal.MoveCellY, Is.EqualTo(hqY), "the defender was not sent to the headquarters"); + Assert.That(goal.HealthPercent, Is.LessThan(shipped.RetreatHealthPercent), + "the subject is not actually under the retreat threshold, so it proves nothing"); + break; + } + Assert.That(seen, Is.True, "the subject was never judged"); + } + // ---------------------------------------------------------------- // Deterministic read helpers (ascending entity index) // ---------------------------------------------------------------- @@ -766,7 +926,20 @@ private static bool TryFirstCombatUnit( return false; } - private static void SpawnEnemyInfantry(SkirmishAiTests.AiHost host, int cellX, int cellY) + /// + /// An armed enemy of the passive seat at a cell. + /// + /// exists for the tests that need the + /// TRIGGER TO HOLD over many cadences: with the shipped health the + /// defenders kill the intruder within two or three of them, and a test + /// about what a standing defence costs would then be measuring the quiet + /// after the fight. An enemy that cannot be killed is not a claim about + /// the game, it is a way to keep one condition true while another is + /// counted. + /// + /// + private static void SpawnEnemyInfantry( + SkirmishAiTests.AiHost host, int cellX, int cellY, int maxHealth = 0) { const byte enemySlot = 0; FactionId faction = host.Economy.GetSlotFaction(enemySlot); @@ -775,10 +948,11 @@ private static void SpawnEnemyInfantry(SkirmishAiTests.AiHost host, int cellX, i enemySlot, new Transform2D(SimFixed.FromInt(cellX), SimFixed.FromInt(cellY)), def.MoveSpeed, - maxHealth: def.MaxHealth, + maxHealth: maxHealth > 0 ? maxHealth : def.MaxHealth, role: UnitRole.BasicInfantry); } + /// /// To the next decision cadence and two ticks further, so the sealed /// intent has landed. Not further: the subject keeps walking, and a long