From d97095abc8b1316f49b5837ca192958217cdfe71 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 16:59:51 +0200 Subject: [PATCH] fix(ai): keep the flanked staging cell inside the wave ring (r10) The D-114 flank shifted the staging cell perpendicular to the line toward the enemy. On the diagonal Glutrinne that moved it to Chebyshev 22 from the HQ, outside the staging ring at 16, so every unit on its way counted as committed and attacked alone: 91-222 attack orders per match before any wave was ready, against 0 without a flank. The flank now scales the offset back onto the staging distance, turning the cell around the HQ. The unflanked path and the neutral personality are unchanged. The Turtle home radius (10 + 4 = 14) reached past the staging cell and called the waiting wave home; Apply now caps what a personality adds one cell short of the staging distance (11 on the shipped profile). Also from the PR #144 review: the flank test now reads the real HQ cell and is checked against the cell the AI reports; a regression test pins "nothing committed, no attack before the first ready wave"; NOVA_MATCH_SEED warns on unparsable values and outside the menu; AiPersonality docstrings and the D-114 changelog text are corrected. AiBehaviorId moves to r10; no new D-ID, D-114 carries a correction. Co-Authored-By: Claude Opus 5.5 --- .../_Project/Scripts/AI.Data/AiBehaviorId.cs | 13 +- .../_Project/Scripts/AI.Data/AiPersonality.cs | 23 +- .../_Project/Scripts/AI/SkirmishAiSystem.cs | 46 +++- .../Scripts/Gameplay/Match/MatchBootstrap.cs | 28 +- CHANGELOG.md | 41 ++- docs/production/DecisionLog.md | 23 +- .../AiPersonalityTests.cs | 248 ++++++++++++++++-- tools/Nova.SimRunner.Tests/SkirmishAiTests.cs | 6 +- .../Nova.SimRunner.Tests/SkirmishGoalTests.cs | 2 +- 9 files changed, 385 insertions(+), 45 deletions(-) diff --git a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs index b5abf61f..4343c06c 100644 --- a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs +++ b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs @@ -170,8 +170,19 @@ public static class AiBehaviorId /// plays a non-neutral personality is still "r9" — the personality /// is printed next to this identifier, not folded into it. /// + /// + /// r10 fixes two defects of r9 found in the review of PR #144. The + /// flank pushed the staging cell perpendicular to the diagonal, to + /// Chebyshev 22 from the HQ and outside the staging ring at 16, so + /// every unit walking there counted as committed and marched alone; the + /// flank now turns the cell and keeps it at the staging distance. And + /// the Turtle's home radius (14) reached past the staging cell; it now + /// stops one cell short. The neutral personality and the canonical + /// outcome are untouched — the revision moves because a flanked or + /// Turtle personality decides differently than it did under r9. + /// /// - public const int Revision = 9; + public const int Revision = 10; /// /// Hash over every value of the shipped profile. Domain-separated like diff --git a/Assets/_Project/Scripts/AI.Data/AiPersonality.cs b/Assets/_Project/Scripts/AI.Data/AiPersonality.cs index 88349470..962f4959 100644 --- a/Assets/_Project/Scripts/AI.Data/AiPersonality.cs +++ b/Assets/_Project/Scripts/AI.Data/AiPersonality.cs @@ -120,6 +120,8 @@ public static AiPersonality Create( army += 4; harvesters = 1; retreat += 5; break; case AiOpening.Turtle: + // home 4 is what the Turtle asks for; Apply caps the result + // under the staging distance (11 on the shipped profile). army += 2; retreat += 5; home = 4; platforms = 1 + (extraDefensePlatform & 1); break; } @@ -133,9 +135,15 @@ public static AiPersonality Create( /// on every machine, which is what lets both peers of a networked match /// agree without a message. /// - /// One hash, independent bit ranges: opening from the low bits, flank - /// from the next byte, and so on, so that no field is a function of - /// another and 512 seeds cover every combination of the small ones. + /// One hash, one shift per field: opening h % 4, flank + /// (h >> 8) % 3, layout (h >> 16) % 8, army jitter + /// (h >> 24) % 3, retreat jitter (h >> 32) % 3, extra + /// platform bit 40. A modulo by 3 reads every bit above its shift, not + /// one byte, so the ranges overlap; the shared high bits are mixed with + /// bits only that field reads, which keeps the fields as good as + /// independent. 4·3·8·3·3·2 = 1728 combinations exist; + /// Derive_IsPure_AndCoversEveryBandOver512Seeds checks that every + /// band of every field is reached, not that every combination is. /// /// public static AiPersonality Derive(ulong matchSeed, byte slot) @@ -174,6 +182,13 @@ public AiProfile Apply(AiProfile baseProfile) if (retreat > 0) retreat = Math.Min(95, Math.Max(0, retreat + RetreatPercentDelta)); int home = baseProfile.DefendHomeCells; if (home > 0) home = Math.Max(0, home + DefendHomeCellsDelta); + // A wider home guard stops one cell short of the gathering point: + // the radius stays under the staging distance, so a skirmish at the + // staging cell does not call the waiting wave home (the invariant + // on AiProfiles.Ms1Canonical.DefendHomeCells; review of PR #144, + // B2). Only what the personality ADDS is capped — a base profile + // that set a wider radius itself keeps it. + home = Math.Min(home, Math.Max(baseProfile.DefendHomeCells, baseProfile.StagingDistanceCells - 1)); return new AiProfile( profileId: baseProfile.ProfileId + "+" + Opening.ToString().ToLowerInvariant(), @@ -198,7 +213,7 @@ public AiProfile Apply(AiProfile baseProfile) defendHomeCells: home); } - /// Compact description for the debug HUD and logs, e.g. Rush, flank +10, layout 5, army 8. + /// Compact description for the debug HUD and logs, e.g. Rush, flank +10, layout 5, army delta -3. public override string ToString() { string flank = FlankOffsetCells > 0 ? "+" + FlankOffsetCells : FlankOffsetCells.ToString(); diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs index 65f6398b..4493ea7a 100644 --- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs +++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs @@ -923,14 +923,22 @@ private UnitRole NextBuildingToPlan(bool hasRefinery, bool hasBarracks, int defe /// cell is the SAME for the whole match — a unit ordered there is not /// re-ordered on the next cadence. /// - /// A personality with a flank offset shifts the cell sideways, - /// perpendicular to the line toward the enemy. The offset is a match - /// constant and the shift reads no live state, so the cell stays a - /// pure function of map and personality — the same every cadence. - /// Deliberately no footprint test: one would let a building placed - /// later flip the cell mid-match, and the unshifted cell never had one - /// either. That the shifted cells stay off the rock is pinned for the - /// canonical map by AiPersonalityTests.FlankedStagingCells_AreInsideTheGridAndWalkable. + /// A personality with a flank offset turns the cell sideways: shifted + /// perpendicular to the line toward the enemy, then scaled back so it + /// stays within (Chebyshev) + /// of the HQ. That bound is what the wave rules stand on — + /// measures its ring from the HQ, and + /// a gathering point outside the ring turns every unit walking there + /// into a lone attacker. The offset is a match constant and the turn + /// reads no live state, so the cell stays a pure function of map and + /// personality — the same every cadence. Deliberately no footprint + /// test: one would let a building placed later flip the cell + /// mid-match, and the unflanked cell never had one either. Both + /// properties are pinned for the canonical map by + /// AiPersonalityTests.FlankedStagingCells_StayInsideTheRingAndWalkable + /// (both slots, mirrored) and + /// AiPersonalityTests.FlankedStagingCell_IsTheOneTheAiReports + /// (slot 1, the production value). /// /// /// Integer division truncates, which is deterministic and identical on @@ -960,8 +968,26 @@ private void GetStagingCell(int hqCellX, int hqCellY, out int cellX, out int cel int flank = Personality.FlankOffsetCells; if (flank != 0) { - cellX = ClampToGrid(cellX + (-dy * flank / span)); - cellY = ClampToGrid(cellY + (dx * flank / span)); + // Shift perpendicular to the line, then scale the offset back + // onto the staging distance: the flank TURNS the cell around + // the HQ instead of pushing it outward. Without the second step + // a diagonal line moves the cell on both axes — on the + // Glutrinne to Chebyshev 22 from the HQ, outside the staging + // ring at 16 — and every unit walking there counted as + // committed and marched alone (review of PR #144, B1). The + // scale-back keeps the direction and leaves an offset that is + // already inside (an axis-aligned line) untouched. The HQ is + // inside the grid, so the clamp can only shorten the offset. + int offsetX = cellX - hqCellX + (-dy * flank / span); + int offsetY = cellY - hqCellY + (dx * flank / span); + int reach = Math.Max(Math.Abs(offsetX), Math.Abs(offsetY)); + if (reach > distance) + { + offsetX = offsetX * distance / reach; + offsetY = offsetY * distance / reach; + } + cellX = ClampToGrid(hqCellX + offsetX); + cellY = ClampToGrid(hqCellY + offsetY); } } diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs index 8b3e9288..5b005510 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs @@ -148,7 +148,9 @@ public sealed class MatchBootstrap : MonoBehaviour /// game. Off by default: the canonical seed stays the default for /// tests and harnesses (the canonical opening-position hash depends on /// it); the main menu switches this on. Network matches take their - /// seed from the relay offer and ignore it. + /// seed from the relay offer and ignore it. NOVA_MATCH_SEED + /// pins the seed of every local start whether this is on or off — see + /// . /// public bool FreshSeedPerLocalMatch { get; set; } @@ -822,6 +824,16 @@ private void BuildOpening(MatchConfig config) /// otherwise a fresh non-zero seed is drawn when /// is on. The seed is the only /// input of the AI personality, so a logged seed reproduces the match. + /// + /// THE PIN APPLIES TO EVERY LOCAL START, with or without + /// — on purpose: a match reported + /// from the menu has to be reproducible through AutoStart and the + /// harnesses too. The price is that a variable left in the shell moves + /// those starts off the canonical seed, so outside the menu the pin is + /// logged as a warning, and a value that does not parse is never + /// dropped silently. The value is always read as hex: a decimal + /// "12345" pins 0x12345, which the log line shows. + /// /// private void ApplyLocalMatchSeed(MatchConfig config) { @@ -835,9 +847,21 @@ private void ApplyLocalMatchSeed(MatchConfig config) if (ulong.TryParse(hex, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out ulong parsed)) { config.Seed = parsed; - Debug.Log($"[MatchBootstrap] Match seed pinned by NOVA_MATCH_SEED: 0x{parsed:X16}."); + if (FreshSeedPerLocalMatch) + { + Debug.Log($"[MatchBootstrap] Match seed pinned by NOVA_MATCH_SEED: 0x{parsed:X16}."); + } + else + { + Debug.LogWarning( + $"[MatchBootstrap] Match seed pinned by NOVA_MATCH_SEED: 0x{parsed:X16}, " + + "replacing the seed of a start outside the menu (AutoStart, harness, PlayMode)."); + } return; } + Debug.LogWarning( + $"[MatchBootstrap] NOVA_MATCH_SEED '{pinned}' is not a hex u64 (at most 16 hex digits, " + + "optional 0x) and was ignored; the match seed is not pinned."); } if (!FreshSeedPerLocalMatch) return; diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af38f47..4d0a6f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,15 +82,22 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de - **Eröffnung:** ausgewogen; Ansturm (Kaserne vor Raffinerie, kleinere und frühere Welle); Ökonom (dritter Sammler, größere Armee); Igel (ein bis zwei Abwehrplattformen Richtung Front). - - **Sammelpunkt:** um zehn Felder zur Seite versetzt oder mittig. + - **Sammelpunkt:** mittig oder zur Seite gedreht (Flanke ±10 Felder), immer + im selben Abstand vom HQ wie der mittige. - **Bauplätze:** eine von acht Suchrichtungen, damit die Basis jedes Mal anders aussieht. - **Profilwerte:** kleine Streuung in festen Bändern. - Das Debug-HUD (F3) und das Startlog zeigen Persönlichkeit und Seed; - `NOVA_MATCH_SEED=` stellt eine gemeldete Partie nach. + `NOVA_MATCH_SEED=` stellt eine gemeldete Partie nach. Die Variable + greift bei jedem lokalen Start, auch außerhalb des Menüs (AutoStart, + Harness, PlayMode); dort schreibt sie eine Warnung ins Log. Ein ungültiger + Wert wird ebenfalls mit einer Warnung gemeldet, nicht still übergangen. - Die neutrale Persönlichkeit spielt exakt wie bisher. Der gepinnte Ausgang der kanonischen KI-Partie bleibt stehen, `AiBehaviorId` steigt auf r9. Netzpartien sind unberührt. + - **Der kanonische Seed spielt nicht mehr neutral.** Er leitet einen + Ansturm-Gegner ab. Wer ohne Menü startet (AutoStart, Harness), spielt also + gegen Ansturm, nicht gegen das Verhalten von r8. - **Bewusst klein:** Solange die KI nur Infanterie und drei Gebäude kennt, variieren Bauordnung, Basisbild, Sammelpunkt und Angriffszeitpunkt, mehr nicht. Panzer, Konter-Bau und Tech folgen als Stufe B. @@ -424,6 +431,36 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt ### Behoben +- **Die KI-Flanke hebelt die Angriffswelle nicht mehr aus (D-114, Review von + #144).** In zwei Dritteln aller Persönlichkeiten griff die KI nicht in Wellen + an, sondern Einheit für Einheit. + - **Ursache:** Die Flanke verschob den Sammelpunkt senkrecht zur Linie zum + Gegner. Auf der diagonalen Glutrinne bewegte das den Punkt auf beiden + Achsen um zehn Felder, also 22 Felder vom HQ statt 12. Der Wellenring + reicht aber nur bis 16. Jede Einheit auf dem Weg zum Sammelpunkt galt + deshalb als „schon mit einer Welle draußen“ und marschierte allein los. + Welle, Stärkegate und Heimverteidigung liefen leer: gemessen 91 bis 222 + Angriffsbefehle ohne fertige Welle je Partie, ohne Flanke null. + - **Behebung:** Die Flanke dreht den Sammelpunkt jetzt um das HQ, statt ihn + hinauszuschieben. Er bleibt im Abstand des mittigen Sammelpunkts (12 + Felder), die Richtung der Verschiebung bleibt erhalten. Gemessen: null + Angriffsbefehle ohne fertige Welle, bei beiden Fraktionen und allen + Flanken. + - **Igel:** Sein erweiterter Heimradius (10 + 4 = 14) reichte über den + Sammelpunkt hinaus. Ein Gegner dort rief die ganze wartende Welle nach + Hause. Der Radius endet jetzt ein Feld vor dem Sammelpunkt, auf dem + ausgelieferten Profil also bei 11. Gedeckelt wird nur, was die + Persönlichkeit hinzufügt. + - Die neutrale Persönlichkeit und der Pfad ohne Flanke ändern sich nicht; + der gepinnte Ausgang der kanonischen KI-Partie bleibt stehen. + `AiBehaviorId` steigt auf r10, weil geflankte und Igel-Persönlichkeiten + anders entscheiden als unter r9. Eine neue D-ID gibt es nicht, D-114 + trägt eine Berichtigung. + - **Tests:** Der Spiegeltest prüfte bisher den Footprint-Ursprung statt der + echten HQ-Zelle. Er liest das HQ jetzt aus der gebauten Partie und + vergleicht sich mit dem Sammelpunkt, den die KI selbst meldet. Neu sind + ein Regressionstest (keine Angriffsbefehle vor der ersten fertigen Welle) + und ein Test für den Igel-Radius. - **Die Bauverweigerung nennt jetzt den Grund (#135).** Aus der Proberunde vom 31.08.2026 kamen zwei Beschwerden, die sich als **dieselbe** erwiesen: „Der zweite Atlas kann keine Gebäude bauen" und „Ich konnte in der Kartenmitte kein diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md index c00a15ca..e5e812dc 100644 --- a/docs/production/DecisionLog.md +++ b/docs/production/DecisionLog.md @@ -1,6 +1,6 @@ # Decision Log -**Version:** 1.46.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21 +**Version:** 1.46.1 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21 ## Zweck @@ -3624,7 +3624,9 @@ das Fähigkeiten-Set des Computergegners je Partie neu. - **Ansturm**: Kaserne vor Raffinerie, kleinere Armee, die früher losschlägt; - **Ökonom**: dritter Sammler und größere Armee; - **Igel**: ein bis zwei Abwehrplattformen zwischen HQ und Sammelpunkt; - - einen seitlich versetzten Sammelpunkt (−10, 0 oder +10 Felder); + - einen zur Seite gedrehten Sammelpunkt (Flanke −10, 0 oder +10 Felder), + der im Abstand des mittigen Sammelpunkts vom HQ bleibt (siehe + Berichtigung); - eine von acht Suchrichtungen für Bauplätze; - kleine Streuungen der Profilwerte in festen Bändern. - Die neutrale Persönlichkeit (`default`) spielt exakt wie r8. Der gepinnte @@ -3662,6 +3664,22 @@ berührt. Ansturm-Gegner ab. Wer ohne Menü startet (AutoStart, Harness), spielt diesen, nicht r8. +**Berichtigung (2026-10-17):** Die Erstfassung nannte den Sammelpunkt +„seitlich versetzt“, und so war er auch umgesetzt: zehn Felder senkrecht zur +Linie zum Gegner. Auf der diagonalen Glutrinne verschob das den Punkt auf +beiden Achsen, 22 Felder vom HQ statt 12, und damit aus dem Wellenring (16) +hinaus. Jede Einheit auf dem Weg dorthin galt als Teil einer schon +abmarschierten Welle und griff allein an. Welle, Stärkegate und +Heimverteidigung liefen in zwei Dritteln aller Persönlichkeiten leer. Das +unabhängige Review von PR #144 hat das gefunden und gemessen. Die Flanke +**dreht** den Sammelpunkt jetzt um das HQ: verschoben wie bisher, dann auf +den Sammelpunktabstand zurückskaliert. Im selben Zug endet der erweiterte +Heimradius des Igels ein Feld vor dem Sammelpunkt (11 statt 14). Sonst ruft +ein Gefecht dort die wartende Welle nach Hause, entgegen der Invariante am +ausgelieferten Profil. Die neutrale Persönlichkeit und der kanonische +Ausgang sind unberührt. `AiBehaviorId` steigt auf r10; eine eigene D-ID gibt +es dafür nicht (Inhaberentscheidung vom 2026-10-17). + ## Offene Punkte - Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden. @@ -3742,6 +3760,7 @@ berührt. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.46.1 | 2026-10-17 | D-114 berichtigt: Die Sammelpunkt-Flanke dreht den Punkt um das HQ, statt ihn aus dem Wellenring (16) hinauszuschieben; der Igel-Heimradius endet ein Feld vor dem Sammelpunkt. Befunde B1 und B2 aus dem Review von PR #144; `AiBehaviorId` r10, keine neue D-ID | Dennis Westermann / Orchestrator | | 1.46.0 | 2026-10-17 | D-114 aufgenommen: Die Skirmish-KI bekommt je Partie eine Persönlichkeit als reine Funktion aus Match-Seed und Slot (Eröffnung, Sammelpunkt-Flanke, Bauplatz-Suchrichtung, Profilstreuung); das Hauptmenü zieht je lokaler Partie einen frischen Seed. Die Versionen 1.42.0 bis 1.45.0 und D-110 bis D-113 trägt der Asset-Pass auf `feat/generals-look-and-feel` | Dennis Westermann / Orchestrator | | 1.41.0 | 2026-08-18 | **D-108 in der Prämisse berichtigt und in der Sache neu gefasst.** Die Erstfassung behauptete, der Code lasse bereits jedes Gebäude als Bauanker gelten, und wollte das nur aussprechen — tatsächlich prüft `IsInsideBuildInfluence` seit D-104 auf HQ, Lager und Kraftwerk. Der ausführende Agent hat den Widerspruch beim Umsetzen von Paket 21.1 gefunden und angehalten. Der Inhaber hat daraufhin in Kenntnis der Lage neu entschieden: die Ankerliste wird bewusst geöffnet, **jedes eigene fertiggestellte Gebäude wird Anker**. Damit ist D-108 eine Verhaltensänderung (`RulesHash64`, Baselines, kanonischer KI-Pin), keine Festschreibung. `MinimumBuildingDistanceCells` bleibt bei 2 — die Messung aus 21.1 (15/23/23) weist die gemeldete Enge nicht als Abstandsproblem aus | Dennis Westermann / Orchestrator | | 1.40.0 | 2026-08-17 | D-108 und D-109 aufgenommen (Sprint 21, aus Testbericht T-01): Territorium wächst **kriechend an jedem eigenen Bauanker** — die bestehende Regel wird festgeschrieben statt geändert, und die gemeldete Enge wird an `MinimumBuildingDistanceCells` gemessen, bevor ein Wert fällt. Die Kartenmitte wird ein Gebiet mit schmalen Zufahrten, mit der verbindlichen Auflage, dass Begehbarkeit und Optik aus **einer** Struktur stammen — heute streut `GlutrinneBlockoutView` begehbare Deko-Felsen, während niemand ausser `ConstructionSystem` in das `CostField` schreibt | Dennis Westermann / Orchestrator | diff --git a/tools/Nova.SimRunner.Tests/AiPersonalityTests.cs b/tools/Nova.SimRunner.Tests/AiPersonalityTests.cs index fbf9df3f..19b96942 100644 --- a/tools/Nova.SimRunner.Tests/AiPersonalityTests.cs +++ b/tools/Nova.SimRunner.Tests/AiPersonalityTests.cs @@ -6,6 +6,7 @@ using Nova.Simulation; using Nova.Simulation.Construction; using Nova.Simulation.Definitions; +using Nova.Simulation.Economy; using Nova.Simulation.State; namespace Nova.SimRunner.Tests @@ -67,7 +68,8 @@ public void Derive_IsPure_AndCoversEveryBandOver512Seeds() case AiOpening.Turtle: Assert.That(army, Is.InRange(13, 15)); Assert.That(retreat, Is.InRange(60, 70)); - Assert.That(10 + p.DefendHomeCellsDelta, Is.EqualTo(14)); + // Asks for 10 + 4; Apply caps it one short of the staging cell (12). + Assert.That(p.Apply(AiProfiles.Ms1Canonical).DefendHomeCells, Is.EqualTo(11)); Assert.That(p.DefensePlatforms, Is.InRange(1, 2)); break; } @@ -241,30 +243,232 @@ public void SeedsWithDifferentOpenings_PlayDifferentMatches() // Flank cells on the real map // ---------------------------------------------------------------- - [TestCase((byte)1, 118, 117, 7)] - [TestCase((byte)0, 4, 7, 117)] - public void FlankedStagingCells_AreInsideTheGridAndWalkable(byte slot, int hqOrigin, int ownField, int enemyField) + /// + /// Every flank on both seats keeps the gathering point inside the + /// staging ring, on the grid and off the rock. + /// + /// THE RING IS THE POINT. IsCommittedToTheWave measures from the + /// HQ cell and calls everything beyond + /// StagingDistanceCells + StagingToleranceCells a unit of an + /// earlier wave. The first version of the flank pushed the cell + /// perpendicular to the diagonal of the Glutrinne, Chebyshev 22 from + /// the HQ — every unit on its way there turned "committed" and marched + /// alone (review of PR #144, B1). + /// + /// + /// The HQ and the enemy field are read off the built match the way the + /// AI reads them — the HQ cell is GridCellOf of its position, not + /// the footprint origin the previous version of this test used (B4). + /// + /// + [TestCase((byte)1)] + [TestCase((byte)0)] + public void FlankedStagingCells_StayInsideTheRingAndWalkable(byte slot) { - _ = ownField; - const int distance = 12; // AiProfiles.Ms1Canonical.StagingDistanceCells - Assert.That(AiProfiles.Ms1Canonical.StagingDistanceCells, Is.EqualTo(distance)); - foreach (int flank in new[] { -10, 10 }) + AiProfile profile = AiProfiles.Ms1Canonical; + int distance = profile.StagingDistanceCells; + int ring = distance + profile.StagingToleranceCells; + + SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed); + Assert.That(SkirmishGoalTests.TryHqCell(host, slot, out int hqX, out int hqY), Is.True); + FarthestFieldCell(host, hqX, hqY, out int enemyX, out int enemyY); + MirrorStagingCell(hqX, hqY, enemyX, enemyY, distance, 0, out int centreX, out int centreY); + + foreach (int flank in new[] { -10, 0, 10 }) + { + MirrorStagingCell(hqX, hqY, enemyX, enemyY, distance, flank, out int x, out int y); + string label = $"slot {slot} (HQ cell {hqX},{hqY}) flank {flank}: cell ({x},{y})"; + int reach = Math.Max(Math.Abs(x - hqX), Math.Abs(y - hqY)); + TestContext.Progress.WriteLine($"[personality] {label}, Chebyshev {reach} from the HQ"); + + Assert.That(reach + profile.StagingToleranceCells, Is.LessThanOrEqualTo(ring), + label + " lies outside the staging ring"); + Assert.That(x, Is.InRange(0, ConstructionSystem.GridSize - 1), label); + Assert.That(y, Is.InRange(0, ConstructionSystem.GridSize - 1), label); + Assert.That(CanonicalTerrainMirror.IsImpassable(x, y), Is.False, label + " is rock"); + if (flank != 0) + Assert.That(x != centreX || y != centreY, Is.True, label + ": the flank must actually move the cell"); + } + } + + /// + /// The mirror above is only worth something while it computes what the + /// AI computes. The AI reports its staging cell to a goal observer, so + /// on the seat the harness can play (slot 1) the mirror is checked + /// against the production value instead of against itself. + /// + [TestCase(-10)] + [TestCase(0)] + [TestCase(10)] + public void FlankedStagingCell_IsTheOneTheAiReports(int flank) + { + var observer = new WaveObserver(); + SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch( + SkirmishAiTests.Seed, goalObserver: observer, + personality: AiPersonality.Create(AiOpening.Balanced, flank, layoutVariant: 0)); + for (int i = 0; i < SkirmishAiTests.EndToEndBudgetTicks && observer.FirstStagingX < 0; i++) host.Step(); + Assert.That(observer.FirstStagingX, Is.GreaterThanOrEqualTo(0), "the AI never resolved a staging cell"); + + Assert.That(SkirmishGoalTests.TryHqCell(host, AiSlot, out int hqX, out int hqY), Is.True); + FarthestFieldCell(host, hqX, hqY, out int enemyX, out int enemyY); + MirrorStagingCell(hqX, hqY, enemyX, enemyY, AiProfiles.Ms1Canonical.StagingDistanceCells, flank, + out int x, out int y); + Assert.That((observer.FirstStagingX, observer.FirstStagingY), Is.EqualTo((x, y))); + } + + /// + /// The regression test for B1, on the behaviour rather than on the + /// geometry: until the first wave is ready, nobody is out with an + /// earlier wave — so nothing may be committed and nobody may be sent to + /// attack. With the gathering point outside the ring the review counted + /// 91 to 222 attack orders in that window; with flank 0 there were none. + /// + [Test] + public void FlankedWave_SendsNobodyBeforeTheFirstWaveIsReady() + { + foreach (FactionId faction in new[] { FactionId.Legion, FactionId.Alliance }) + { + foreach (int flank in new[] { -10, 0, 10 }) + { + var observer = new WaveObserver(); + SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch( + SkirmishAiTests.Seed, goalObserver: observer, aiFaction: faction, + personality: AiPersonality.Create(AiOpening.Balanced, flank, layoutVariant: 0)); + for (int i = 0; i < SkirmishAiTests.EndToEndBudgetTicks && observer.FirstWaveTick == 0 + && !host.Victory.IsDecided; i++) + { + host.Step(); + } + + string label = $"{faction} flank {flank}"; + TestContext.Progress.WriteLine($"[personality] {label}: staging ({observer.FirstStagingX}," + + $"{observer.FirstStagingY}), {observer.GatheringDecisions} gathering decisions, " + + $"first wave at tick {observer.FirstWaveTick}, committed early {observer.CommittedEarly}, " + + $"attacks early {observer.AttacksEarly}"); + Assert.That(observer.FirstWaveTick, Is.Not.Zero, label + ": no wave was ever ready"); + Assert.That(observer.GatheringDecisions, Is.GreaterThan(0), label + ": the army never gathered"); + Assert.That(observer.CommittedEarly, Is.Zero, label + ": units counted as committed before any wave marched"); + Assert.That(observer.AttacksEarly, Is.Zero, label + ": attack orders before the first wave was ready"); + } + } + } + + /// + /// Watches the window before the first wave: army decisions that act + /// while the wave is not ready, and every unit goal inside them. + /// + private sealed class WaveObserver : IAiGoalObserver + { + public int FirstStagingX = -1, FirstStagingY = -1; + public uint FirstWaveTick; + public int GatheringDecisions, CommittedEarly, AttacksEarly; + private bool _gathering; + + public void OnArmyGoal(byte slot, uint tick, in AiArmyGoal army) + { + _gathering = false; + if (!army.Engages) return; + if (FirstStagingX < 0 && army.StagingCellX >= 0) + { + FirstStagingX = army.StagingCellX; + FirstStagingY = army.StagingCellY; + } + if (FirstWaveTick != 0) return; + if (army.WaveReady) + { + FirstWaveTick = tick; + return; + } + _gathering = true; + GatheringDecisions++; + CommittedEarly += army.Committed; + } + + public void OnUnitGoal(byte slot, uint tick, in AiUnitGoal goal) + { + if (_gathering && goal.Goal == GoalKind.Attack) AttacksEarly++; + } + } + + // ---------------------------------------------------------------- + // Turtle home radius + // ---------------------------------------------------------------- + + /// + /// The Turtle widens the home guard, but not onto the gathering point: + /// a radius that reaches the staging cell turns a skirmish there into a + /// call home for the whole waiting wave (review of PR #144, B2; the + /// invariant is documented on AiProfiles.Ms1Canonical). + /// + [Test] + public void TurtleHomeRadius_StaysShortOfTheStagingCell() + { + var shipped = AiProfiles.Ms1Canonical; + var matchRunnerBase = new AiFactionProfile("Legion", + targetPowerMargin: 0, targetArmySize: 12, attackSquadThreshold: 6, targetHarvesterCount: 2).Profile; + foreach (AiProfile baseProfile in new[] { shipped, matchRunnerBase }) + { + AiProfile turtle = AiPersonality.Create(AiOpening.Turtle, 0, 0).Apply(baseProfile); + Assert.That(turtle.DefendHomeCells, Is.LessThan(turtle.StagingDistanceCells)); + Assert.That(turtle.DefendHomeCells, Is.GreaterThan(baseProfile.DefendHomeCells), + "the Turtle still guards a wider home than the base profile"); + } + + // Only what the personality adds is capped: a base profile that set + // a wider radius itself — to measure it, say — keeps its own value. + AiProfile wide = new AiProfile( + "wide", shipped.DecisionTickInterval, shipped.PlacementSearchRadius, 0, 2, + shipped.HarvesterQueueBatch, 12, 6, shipped.InfantryQueueBatch, + shipped.TargetDamageWeight, shipped.TargetThreatWeight, shipped.TargetFinishWeight, + shipped.TargetDistanceWeight, shipped.WaveSize, shipped.StagingDistanceCells, + shipped.StagingToleranceCells, shipped.RetreatHealthPercent, shipped.RetreatDangerCells, + shipped.WaveStrengthPoints, defendHomeCells: 13); + Assert.That(AiPersonality.Create(AiOpening.Turtle, 0, 0).Apply(wide).DefendHomeCells, Is.EqualTo(13)); + } + + // ---------------------------------------------------------------- + // Mirrors of the AI's static map knowledge + // ---------------------------------------------------------------- + + /// Mirror of SkirmishAiSystem.GetEnemyStartAreaCell: the registered field farthest from the HQ, lowest id on a tie. + private static void FarthestFieldCell(SkirmishAiTests.AiHost host, int hqX, int hqY, out int cellX, out int cellY) + { + cellX = ConstructionSystem.GridSize / 2; + cellY = ConstructionSystem.GridSize / 2; + long best = -1; + for (ushort id = 1; id <= EconomySystem.MaxFields; id++) + { + if (!host.Economy.TryGetField(id, out AetheriumField field)) continue; + long dx = field.GridPos.X - hqX; + long dy = field.GridPos.Y - hqY; + if (dx * dx + dy * dy <= best) continue; + best = dx * dx + dy * dy; + cellX = field.GridPos.X; + cellY = field.GridPos.Y; + } + } + + /// Mirror of SkirmishAiSystem.GetStagingCell, flank branch included. + private static void MirrorStagingCell( + int hqX, int hqY, int enemyX, int enemyY, int distance, int flank, out int cellX, out int cellY) + { + int dx = enemyX - hqX; + int dy = enemyY - hqY; + int span = Math.Max(Math.Abs(dx), Math.Abs(dy)); + Assert.That(span, Is.GreaterThan(distance), "the enemy field lies inside the staging distance"); + cellX = Clamp(hqX + (dx * distance / span)); + cellY = Clamp(hqY + (dy * distance / span)); + if (flank == 0) return; + int offsetX = cellX - hqX + (-dy * flank / span); + int offsetY = cellY - hqY + (dx * flank / span); + int reach = Math.Max(Math.Abs(offsetX), Math.Abs(offsetY)); + if (reach > distance) { - // Mirror of SkirmishAiSystem.GetStagingCell (flank branch); the enemy start area is the far field. - int dx = enemyField - hqOrigin; - int dy = enemyField - hqOrigin; - int span = Math.Max(Math.Abs(dx), Math.Abs(dy)); - Assert.That(span, Is.GreaterThan(distance)); - int cx = Clamp(hqOrigin + dx * distance / span); - int cy = Clamp(hqOrigin + dy * distance / span); - int fx = Clamp(cx + (-dy * flank / span)); - int fy = Clamp(cy + (dx * flank / span)); - Assert.That(fx, Is.InRange(0, ConstructionSystem.GridSize - 1), $"slot {slot} flank {flank}"); - Assert.That(fy, Is.InRange(0, ConstructionSystem.GridSize - 1), $"slot {slot} flank {flank}"); - Assert.That(CanonicalTerrainMirror.IsImpassable(fx, fy), Is.False, - $"slot {slot} flank {flank}: cell ({fx},{fy}) is rock"); - Assert.That(fx != cx || fy != cy, Is.True, "the flank must actually move the cell"); + offsetX = offsetX * distance / reach; + offsetY = offsetY * distance / reach; } + cellX = Clamp(hqX + offsetX); + cellY = Clamp(hqY + offsetY); } private static int Clamp(int c) => Math.Max(0, Math.Min(ConstructionSystem.GridSize - 1, c)); diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs index 01cfcf50..1a46510b 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs @@ -562,7 +562,11 @@ public void AiBehaviorId_TracksWhichAiThisIs() // 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("r9.1E6E7AE3"), + // + // r10 moves the identifier and not the canonical match: the fix + // only reaches flanked and Turtle personalities, and the canonical + // match plays the neutral one. + Assert.That(AiBehaviorId.Value, Is.EqualTo("r10.1E6E7AE3"), "the AI identifier changed — bump the revision and write the journal entry"); } diff --git a/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs index 53ef4082..3c82be42 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs @@ -862,7 +862,7 @@ private static bool AnyCombatUnitOrderedTo( return false; } - private static bool TryHqCell(SkirmishAiTests.AiHost host, byte slot, out int cellX, out int cellY) + internal 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++)