diff --git a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs index b5abf61..4343c06 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 8834947..962f495 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 65f6398..4493ea7 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 8b3e928..5b00551 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 3af38f4..4d0a6f1 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 c00a15c..e5e812d 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 fbf9df3..19b9694 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 01cfcf5..1a46510 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 53ef408..3c82be4 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++)