diff --git a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs index 18e6f78..2b37c15 100644 --- a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs +++ b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using NUnit.Framework; using Nova.AI; +using Nova.AI.Data; using Nova.Core; using Nova.Simulation; using Nova.Simulation.Combat; @@ -114,7 +116,8 @@ public uint RunUntilDecided(int budgetTicks) } } - private static AiHost BuildAiHost(ulong seed) + private static AiHost BuildAiHost( + ulong seed, IAiGoalObserver goalObserver = null, AiPersonality personality = default) { // Mirror of MatchRunner.InitializeMatch(seed, ..., enableSkirmishAi: true). var kernel = new SimulationKernel(new SimRandom(seed)); @@ -136,11 +139,14 @@ private static AiHost BuildAiHost(ulong seed) var aiSession = new MatchSession(AiSlot, activeSlots: new byte[] { HumanSlot, AiSlot }, inputDelayTicks: 1); var aiIngress = new CommandIngress(aiSession); _ = new AiPeerCommandTransport(aiIngress, ingress); + // Applied the way MatchRunner does it: deltas into the profile, the rest by argument. var ai = new SkirmishAiSystem( AiSlot, new AiFactionProfile("Legion", - targetPowerMargin: 0, targetArmySize: 12, attackSquadThreshold: 6, targetHarvesterCount: 2), - aiIngress, entities, economy, construction, production, fogOfWar, victory); + targetPowerMargin: 0, targetArmySize: 12, attackSquadThreshold: 6, targetHarvesterCount: 2) + .WithPersonality(personality), + aiIngress, entities, economy, construction, production, fogOfWar, victory, + goalObserver, null, personality); kernel.RegisterSystem(economy); kernel.RegisterSystem(construction); @@ -212,9 +218,10 @@ private static void ApplyOpeningPosition(AiHost host) } } - private static AiHost BuildMatch(ulong seed) + private static AiHost BuildMatch( + ulong seed, IAiGoalObserver goalObserver = null, AiPersonality personality = default) { - AiHost host = BuildAiHost(seed); + AiHost host = BuildAiHost(seed, goalObserver, personality); ApplyOpeningPosition(host); return host; } @@ -476,5 +483,119 @@ public void SkirmishAi_EndToEnd_IsByteDeterministicAcrossRuns() Assert.That(first.Kernel.CalculateStateHash(), Is.EqualTo(second.Kernel.CalculateStateHash()), "the full AI loop (intents through the sealed stream) must reproduce the identical end state"); } + + // ---------------------------------------------------------------- + // (f) The flanked staging cell (r11, #101, D-127) + // + // Mirror of the .NET lane SkirmishGoalTests flank block: a D-114 + // flank of ten put the staging cell outside the commit ring, so no + // unit held there and the army marched piecemeal before its wave + // was ready. + // ---------------------------------------------------------------- + + private sealed class RecordingObserver : IAiGoalObserver + { + public readonly List<(uint Tick, AiArmyGoal Goal)> Army = new List<(uint, AiArmyGoal)>(); + public readonly List<(uint Tick, AiUnitGoal Goal)> Units = new List<(uint, AiUnitGoal)>(); + + public void OnArmyGoal(byte slot, uint tick, in AiArmyGoal army) => Army.Add((tick, army)); + + public void OnUnitGoal(byte slot, uint tick, in AiUnitGoal goal) => Units.Add((tick, goal)); + } + + [TestCase(10)] + [TestCase(-10)] + public void SkirmishAi_FlankedStagingCellIsInsideTheRing_AndProducesHold(int flank) + { + AiProfile shipped = AiProfiles.Ms1Canonical; + var observer = new RecordingObserver(); + AiHost host = BuildMatch(Seed, observer, AiPersonality.Create(AiOpening.Balanced, flank, layoutVariant: 0)); + Assert.That(TryHqCell(host, AiSlot, out int hqX, out int hqY), Is.True); + uint firstWave = RunToFirstWave(host, observer); + Assert.That(firstWave, Is.Not.Zero, $"flank {flank}: the first wave never marched"); + + AiArmyGoal army = FirstEngagingArmy(observer); + int stagingDistance = Math.Max(Math.Abs(army.StagingCellX - hqX), Math.Abs(army.StagingCellY - hqY)); + Assert.That(stagingDistance, Is.GreaterThan(shipped.StagingDistanceCells + shipped.StagingToleranceCells), + $"flank {flank}: the staging cell is inside the unflanked ring, so this scene does not reproduce #101"); + Assert.That(stagingDistance, Is.LessThanOrEqualTo(shipped.StagingDistanceCells + Math.Abs(flank))); + + int holds = 0; + foreach ((uint tick, AiArmyGoal goal) in observer.Army) + { + if (tick >= firstWave || !goal.Engages) continue; + Assert.That(goal.Committed, Is.Zero, + $"flank {flank}, tick {tick}: units counted as already out before any wave marched"); + } + foreach ((uint tick, AiUnitGoal goal) in observer.Units) + { + if (tick < firstWave && goal.Goal == GoalKind.Hold) holds++; + } + Assert.That(holds, Is.GreaterThan(0), + $"flank {flank}: not one unit was told to hold at the staging cell before the first wave"); + } + + [TestCase(10)] + [TestCase(-10)] + [TestCase(0)] + public void SkirmishAi_NoAttackBeforeWaveReady_WhenFlanked(int flank) + { + var observer = new RecordingObserver(); + AiHost host = BuildMatch(Seed, observer, AiPersonality.Create(AiOpening.Balanced, flank, layoutVariant: 0)); + uint firstWave = RunToFirstWave(host, observer); + Assert.That(firstWave, Is.Not.Zero, $"flank {flank}: the first wave never marched"); + + int attacksAtTheWave = 0; + foreach ((uint tick, AiUnitGoal goal) in observer.Units) + { + if (goal.Goal != GoalKind.Attack) continue; + Assert.That(tick, Is.GreaterThanOrEqualTo(firstWave), + $"flank {flank}: unit {goal.EntityRaw} was sent to attack at tick {tick}, " + + $"before the first wave was ready at tick {firstWave}"); + if (tick == firstWave) attacksAtTheWave++; + } + Assert.That(attacksAtTheWave, Is.GreaterThan(0), + $"flank {flank}: the wave was reported ready at tick {firstWave} and nobody marched"); + } + + private static uint RunToFirstWave(AiHost host, RecordingObserver observer) + { + for (int budget = 0; budget < EndToEndBudgetTicks && !host.Victory.IsDecided; budget++) + { + host.Step(); + for (int i = observer.Army.Count - 1; i >= 0; i--) + { + if (observer.Army[i].Goal.Engages && observer.Army[i].Goal.WaveReady) return observer.Army[i].Tick; + } + } + return 0; + } + + private static AiArmyGoal FirstEngagingArmy(RecordingObserver observer) + { + foreach ((uint _, AiArmyGoal goal) in observer.Army) + { + if (goal.Engages && goal.StagingCellX >= 0) return goal; + } + Assert.Fail("the army never engaged with a resolved staging cell"); + return default; + } + + private static bool TryHqCell(AiHost host, byte slot, out int cellX, out int cellY) + { + UnitState[] units = host.Entities.RawUnits; + for (int i = 0; i < host.Entities.Capacity; 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; + } } } diff --git a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs index 2adc6e2..43c3ffb 100644 --- a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs +++ b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs @@ -185,8 +185,22 @@ public static class AiBehaviorId /// with the production change underneath, not with this rule (both /// measured on the same head, see CanonicalAiOutcomeTests). /// + /// + /// r11 widens the commit ring by the personality's flank (#101, + /// D-127). r9 shifted the staging cell sideways but left the ring that + /// tells "still gathering" from "already out" at + /// StagingDistanceCells + StagingToleranceCells; with a flank of + /// ten the shifted cell lay outside it, so every arriving unit counted + /// as committed — no Hold, a piecemeal march long before the wave was + /// ready, and no gatherer left for DefendHome. The ring is now + /// StagingDistanceCells + |FlankOffsetCells| + StagingToleranceCells, + /// still around the HQ. No number moved, so is + /// unchanged; the NEUTRAL personality has flank 0 and plays r10 tick for + /// tick, so the canonical outcome pin does not move either. Only + /// flanked personalities change — two of the three flank bands. + /// /// - public const int Revision = 10; + public const int Revision = 11; /// /// Hash over every value of the shipped profile. Domain-separated like diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs index 12d45b8..e4d1d4f 100644 --- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs +++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs @@ -954,6 +954,9 @@ private UnitRole NextBuildingToPlan(bool hasRefinery, bool hasBarracks, int defe /// 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. + /// The commit ring in grows by the + /// same offset, so the shifted cell stays a place where units WAIT + /// (r11, D-127). /// /// /// Integer division truncates, which is deterministic and identical on @@ -1197,8 +1200,27 @@ private bool IsAtTheStagingCell(in UnitState unit, in ArmyPosture posture) /// size forever and the army oscillated between the base and the /// staging point for 11.000 ticks while a single enemy unit ground /// down its HQ. The ring is the whole area inside - /// StagingDistanceCells + StagingToleranceCells, so where - /// exactly a unit stands while it waits does not matter. + /// StagingDistanceCells + |FlankOffsetCells| + StagingToleranceCells, + /// so where exactly a unit stands while it waits does not matter. + /// + /// + /// THE FLANK IS PART OF THE RING (r11, #101, D-127). A personality + /// shifts the staging cell sideways by + /// , and the ring used to + /// ignore that: with a flank of ten the cell sat 22 cells from the HQ + /// on the canonical map, outside a ring of 16. Every unit that arrived + /// there counted as committed, none was ever told to hold, each one + /// marched on its own before the wave was full, and DefendHome, which + /// only takes units that are not committed, had nobody left to call + /// back — in two of three menu matches (flank bands -10, 0, +10). + /// The ring grows by the flank's size, still measured from the HQ: the + /// shift is |flank| * max(|dx|, |dy|) / span cells at most, and + /// span IS that maximum, so in Chebyshev distance the shifted cell is + /// never further than StagingDistanceCells + |flank| from the HQ + /// (clamping into the grid only pulls it closer, the HQ being on the + /// grid). Measuring around the staging cell instead was rejected for + /// the reason above — it is the version that oscillated. A neutral + /// personality has flank 0 and keeps the ring it always had. /// /// private bool IsCommittedToTheWave(in UnitState unit, int hqCellX, int hqCellY) @@ -1207,7 +1229,9 @@ private bool IsCommittedToTheWave(in UnitState unit, int hqCellX, int hqCellY) int cellY = GridCellOf(unit.Transform.PositionY); int dx = Math.Abs(cellX - hqCellX); int dy = Math.Abs(cellY - hqCellY); - int ring = _profile.Profile.StagingDistanceCells + _profile.Profile.StagingToleranceCells; + int ring = _profile.Profile.StagingDistanceCells + + Math.Abs(Personality.FlankOffsetCells) + + _profile.Profile.StagingToleranceCells; return Math.Max(dx, dy) > ring; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 65be6ae..27ef407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -440,6 +440,14 @@ 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 sammelt auch am seitlich versetzten Sammelpunkt (Refs #101, D-127).** + Der D-114-Flankenversatz von ±10 Feldern legte den Sammelpunkt 22 Felder vom + HQ entfernt, der Commit-Ring maß aber nur 16. Jede ankommende Einheit galt als + ausgerückt: kein `Hold`, Einzelangriffe ab Tick 1.100 statt der vollen Welle + ab Tick 1.540, und `DefendHome` fand keine Wartenden mehr – in zwei von drei + Menüpartien. Der Ring misst jetzt `StagingDistanceCells + |Flanke| + + StagingToleranceCells`, weiter ab dem HQ. `AiBehaviorId` r11, Profil-Hash und + kanonischer KI-Ausgang unverändert (neutrale Persönlichkeit ohne Flanke) - **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 c0c3a4e..8f2981d 100644 --- a/docs/production/DecisionLog.md +++ b/docs/production/DecisionLog.md @@ -3588,6 +3588,13 @@ Mitte sind ausdrücklich nicht Teil dieser Entscheidung. **Status:** Inhaberentscheidung vom 2026-10-17. Die Nummern D-110 bis D-113 sind vom Asset-Pass auf `feat/generals-look-and-feel` belegt. +**Statusvermerk (2026-10-17, D-127-Entwurf, Refs #101):** Berichtigt durch +D-127. Der seitlich versetzte Sammelpunkt lag außerhalb des Commit-Rings der +Welle; geflankte Persönlichkeiten griffen deshalb stückweise vor der vollen +Welle an. Die Konsequenz „zwischen rund 2.000 und 3.000 Ticks“ unten maß diesen +Defekt mit: Das untere Ende stammte von Flanke +10. Nach D-127 liegt dieselbe +Matrix bei 2.208 bis 2.871 Ticks. Der Text unten bleibt unverändert stehen. + **Kontext:** Jede lokale Partie gegen die KI lief identisch ab. Am Code geprüft (`main` @ `85c912b`), stapeln sich drei Ursachen: @@ -3775,6 +3782,102 @@ Gemessen auf `a6858d1` mit diesem Stand, headless (`dotnet test`): auf das KI-Feld. Ein KI-Ausbau auf mehrere Felder (D-114, Stufe B) braucht dafür eine eigene Regel. +### D-127 | Entwurf | KI-Strang (der Commit-Ring wächst um den Flankenversatz des Sammelpunkts) + +**Status:** Entwurf aus dem Issue-Lauf vom 2026-10-17 (Refs #101); verbindlich +mit dem Merge. Berichtigt eine Konsequenz aus D-114 per Statusvermerk dort. + +**Kontext:** D-114 verschiebt den Sammelpunkt je Persönlichkeit um −10, 0 oder ++10 Felder quer zur Linie zum Gegner. `SkirmishAiSystem.IsCommittedToTheWave` +unterscheidet „sammelt noch“ von „ist schon ausgerückt“ über einen Ring um das +eigene HQ, und der maß weiter `StagingDistanceCells + StagingToleranceCells` += 16 Felder. Der versetzte Sammelpunkt liegt auf der kanonischen Karte bei +(117,97) bzw. (97,117), Chebyshev 22 vom HQ (119,119), also außerhalb. Jede +Einheit, die dort ankam, galt als ausgerückt. Gemessen headless auf +`dafc7db` (kanonischer Seed, Ausgewogen, Layout 0, passiver Gegner): + +- Flanke +10: 0 `Hold`, 171 `Attack`-Entscheidungen vor der ersten vollen + Welle (Tick 1.540), der erste Angriff bei Tick 1.120, bis zu 11 Einheiten + vor der ersten Welle als ausgerückt gezählt. +- Flanke −10: 0 `Hold`, 91 `Attack`-Entscheidungen, erster Angriff bei + Tick 1.100. +- Flanke 0 (Kontrolle): 177 `Hold`, erster Angriff mit der Welle bei Tick 1.540. + +Die Armee lief also stückweise los, lange bevor die Welle stand. `DefendHome` +nimmt nur nicht ausgerückte Einheiten, fand am versetzten Sammelpunkt also +niemanden. `AiPersonality.Derive` wählt die Flanke gleichverteilt; für Slot 1 +sind 2.000 von 3.000 geprüften Seeds geflankt. Jede Verteidigungsregel aus #101 +hätte in diesen Partien nur halb gegriffen. Der kanonische Seed +(`MatchBootstrap.CanonicalSeed`) leitet Flanke 0 ab und ist nicht betroffen. + +**Alternativen:** + +1. **Den Ring um den Flankenbetrag erweitern**, weiter ab dem HQ gemessen: + `StagingDistanceCells + |FlankOffsetCells| + StagingToleranceCells`. Eine + Zeile, keine neue Profilzahl, die neutrale Persönlichkeit behält ihren Ring + von 16. Die Grenze ist beweisbar dicht: Der Versatz ist je Achse höchstens + `|flank| · max(|dx|,|dy|) / span = |flank|`, das Klemmen ins Raster zieht + nur zum HQ hin. Nachteil: Ein geflankter Ring ist 26 Felder groß. Eine + Einheit, die mit einer Welle losläuft, gilt etwas länger als „sammelnd“. + Das ist dieselbe Spanne von vier Feldern hinter dem Sammelpunkt wie + ungeflankt. +2. **Relativ zum Sammelpunkt messen** (ausgerückt = weiter als die Toleranz vom + Sammelpunkt). Unabhängig von jeder Geometrie, aber genau die Fassung, die + der Docstring von `IsCommittedToTheWave` als gescheitert dokumentiert: Die + Formationsverteilung streut eine Gruppe über mehr als vier Felder, die + Zählung blieb unter der Wellengröße, und die Armee pendelte 11.000 Ticks + zwischen Basis und Sammelpunkt. Ein großzügigerer Radius um den Sammelpunkt + wäre eine neue, ungemessene Zahl und würde zwischen HQ und Sammelpunkt + stehende Einheiten (frisch produziert, auf dem Weg) als ausgerückt zählen. +3. **Die Flanke begrenzen**, etwa auf ±4, damit der Sammelpunkt im Ring von 16 + bleibt. Ändert D-114 inhaltlich (Abwechslung im Basisbild), verschiebt alle + geflankten Partien und koppelt zwei unabhängige Zahlen über eine stille + Ungleichung, die beim nächsten Profilwechsel wieder reißt. +4. **Den Ring je Partie aus dem tatsächlichen Sammelpunkt ableiten** + (Chebyshev-Abstand Sammelpunkt–HQ plus Toleranz). Für die heutige Formel + gleichwertig zu 1, aber eine zweite Herleitung derselben Geometrie, die bei + geklemmten Randfällen leicht kleiner ausfällt. Ohne messbaren Gewinn + gegenüber 1. + +**Entscheidung:** Alternative 1. + +- `IsCommittedToTheWave` misst + `StagingDistanceCells + |Personality.FlankOffsetCells| + StagingToleranceCells` + ab der HQ-Zelle. Der Docstring nennt den Grund und die Schranke. +- `AiBehaviorId` steigt auf r11. Der Profil-Hash bleibt (`1E6E7AE3`), weil + keine Zahl dazukommt. +- `CanonicalAiOutcomeTests` bleibt unverändert: Die neutrale Persönlichkeit hat + Flanke 0 und spielt r10 Tick für Tick (gemessen: Tick 2.726, Endzustand + `0x7AF32DBA56E393E2` vor und nach der Änderung). + +**Begründung:** Der Defekt liegt nicht in der Wellenlogik, sondern darin, dass +D-114 den Ort des Sammelns verschoben hat, ohne den Ring mitzunehmen. Die +kleinste Korrektur nimmt ihn mit. Die HQ-Messung bleibt, weil sie das +dokumentierte Pendeln verhindert, und keine Persönlichkeit verliert ihre +Abwechslung. Nach der Änderung: Flanke ±10 erzeugt 159 bzw. 166 `Hold` und kein +`Attack` vor Tick 1.540; ausgerückt gezählt wird vor der ersten Welle niemand. + +**Konsequenzen:** + +- **Geflankte Partien entscheiden anders.** In der Matrix aus + `AiPersonalityTests.EveryOpening_FlankAndFaction_FinishesTheMatch` (vier + Eröffnungen, beide Fraktionen, Layout 5, passiver Gegner) entschied Flanke +10 + vorher 165 bis 458 Ticks *früher* als Flanke 0. Das war der stückweise + Angriff, der gegen einen wehrlosen Gegner schneller ist. Jetzt liegt jede + Flanke 11 bis 159 Ticks hinter Flanke 0 (längerer Weg über den Sammelpunkt). + Die Spanne über alle 24 Partien wandert von 1.987–2.982 auf 2.208–2.871 Ticks. +- **D-114 wird per Statusvermerk berichtigt**, nicht umgeschrieben: Die + Konsequenz „zwischen rund 2.000 und 3.000 Ticks“ maß den Defekt mit. +- **Mehr Verteidiger.** Weil wartende Einheiten am versetzten Sammelpunkt nicht + mehr als ausgerückt gelten, nimmt `DefendHome` sie bei einem Angriff auf das + HQ wieder mit. Das ist die Vorbedingung für die Feld- und Harvester-Regel aus + #101 (Paket P-101b). +- **Nicht gespielt.** Belegt ist das headless gegen einen passiven Gegner. Wie + sich die volle Welle am versetzten Sammelpunkt gegen einen Menschen spielt, + ist offen. +- **Kein eigenes GitHub-Issue** für den Defekt ohne Freigabe; der PR trägt + „Refs #101“. + ## Offene Punkte - Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden. diff --git a/reports/v8.6.0/issue-lauf-2026-10-17/09-issue-101a-flanked-staging-ring.md b/reports/v8.6.0/issue-lauf-2026-10-17/09-issue-101a-flanked-staging-ring.md new file mode 100644 index 0000000..b3a7deb --- /dev/null +++ b/reports/v8.6.0/issue-lauf-2026-10-17/09-issue-101a-flanked-staging-ring.md @@ -0,0 +1,190 @@ +--- +issue: 101 +paket: P-101a +branch: fix/101-flanked-staging-commit-ring +agent: builder (opus) +datum: 2026-10-17 +status: DONE +--- +# #101 (Vorbedingung): Der flankierte Sammelpunkt liegt im Commit-Ring + +## Aufgabe + +D-114 versetzt den Sammelpunkt der Skirmish-KI je Persönlichkeit um ±10 Felder +seitlich. `SkirmishAiSystem.IsCommittedToTheWave` maß den Ring für „sammelt +noch / ist ausgerückt“ weiter mit `StagingDistanceCells + StagingToleranceCells` += 16 ab dem HQ. Der versetzte Sammelpunkt liegt aber auf Chebyshev 22. Paket +P-101a: Defekt selbst nachweisen, dann den Ring um `|FlankOffsetCells|` +erweitern (weiter ab dem HQ gemessen), Docstring, `AiBehaviorId` r11, Tests in +beiden Lanes, D-127-Entwurf, Statusvermerk an D-114 und CHANGELOG. + +Gestapelt auf `feat/134-harvester-born-harvesting` @ `dafc7db` (r10). + +## Verifikation des Defekts (vor der Änderung) + +1. **Tests zuerst, rot auf dem unveränderten Code.** Die neuen Tests in + `SkirmishGoalTests` liefen gegen `dafc7db` plus nur die Tests: + 6 Fehler, 4 grün (Filter `~Flank`, 10 Tests). Die Meldungen: + - `flank 10, tick 1120: 6 unit(s) counted as already out before any wave marched` + - `flank -10, tick 1100: 1 unit(s) counted as already out …` + - `flank 10: unit 1033 was sent to attack at tick 1120, before the first wave was ready at tick 1540` + - `flank -10: unit 1038 was sent to attack at tick 1100, before … 1540` + - `FlankedGatherersStillBreakOffToDefendTheBase(±10)`: es hielt nie jemand, die Welle lief zuerst. + - Die Kontrolle `NoAttackBeforeWaveReady_WhenFlanked(0)` war grün. +2. **Eigene Probe** (Scratch, nicht im Repo; kompiliert die Quellen dieses + Worktrees, kanonischer Seed, Ausgewogen, Layout 0, passiver Gegner, bis + zur Entscheidung): + + | Flanke | Sammelpunkt | erste Welle | erster Angriff | max. „ausgerückt“ vor Welle | Goals vor der Welle | entschieden | + |---|---|---|---|---|---|---| + | −10 | (97,117) | 1540 | 1100 | 6 | Advance 113, Attack 91 | 2735 | + | 0 | (107,107) | 1540 | 1540 | 0 | Advance 27, Hold 177 | 2726 | + | +10 | (117,97) | 1540 | 1120 | 11 | Advance 33, Attack 171 | 2458 | + + Das bestätigt die Triage-Probe (171 bzw. 91 Attack, kein Hold). +3. **Anteil der Partien:** `AiPersonality.Derive(seed·φ+1, slot 1)` über 3.000 + Seeds: 2.000 geflankt, also zwei Drittel. Der kanonische Seed + `0xDE7E000000010271` leitet `Rush, flank 0, layout 3, army delta -3` ab und + ist nicht betroffen. + +## Files Changed + +- `Assets/_Project/Scripts/AI/SkirmishAiSystem.cs`: `IsCommittedToTheWave` rechnet `ring = StagingDistanceCells + Math.Abs(Personality.FlankOffsetCells) + StagingToleranceCells`. Der Docstring hat einen neuen Absatz (r11, #101, D-127) mit Befund, Chebyshev-Schranke (Versatz je Achse ≤ |flank|, Klemmen zieht nur zum HQ hin) und dem Hinweis, warum nicht relativ zum Sammelpunkt gemessen wird. Der `GetStagingCell`-Docstring verweist auf den mitwachsenden Ring. +- `Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs`: `Revision` 10 → 11, mit r11-History-Absatz. Der Profil-Hash bleibt. +- `tools/Nova.SimRunner.Tests/SkirmishAiTests.cs`: Identitätspin `r10.1E6E7AE3` → `r11.1E6E7AE3` mit r11-Kommentar. +- `tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs`: Neuer Block „The flanked staging cell (r11, #101, D-127)“ mit drei Tests (7 Fälle) und den Helfern `FlankedHost`, `RunToFirstWave`, `AnyWaveReady`, `FirstEngagingArmy`: + - `FlankedStagingCellIsInsideTheRing_AndProducesHold(±10)`: Vorbedingung, dass der Sammelpunkt außerhalb des alten Rings liegt (> 16) und höchstens `12 + |flank|` vom HQ entfernt ist. Vor der ersten Welle ist `Committed == 0`, und es gibt mindestens ein `Hold`. + - `NoAttackBeforeWaveReady_WhenFlanked(±10, 0)`: kein `Attack` vor dem ersten `WaveReady`, und an diesem Tick marschiert jemand. Flanke 0 ist die Kontrolle. + - `FlankedGatherersStillBreakOffToDefendTheBase(±10)`: Ein Feind am HQ, solange am versetzten Sammelpunkt gehalten wird, erzeugt `DefendHome` und kein `Attack`. +- `Assets/Tests/EditMode/AI/SkirmishAiTests.cs`: Der Spiegel bekommt optionale Parameter `goalObserver`/`personality` an `BuildAiHost`/`BuildMatch`, wie in der .NET-Lane. Der Profilaufbau läuft über `.WithPersonality(personality)`; neutral ist das identisch. Neu sind ein `RecordingObserver` und die Tests `SkirmishAi_FlankedStagingCellIsInsideTheRing_AndProducesHold(±10)` und `SkirmishAi_NoAttackBeforeWaveReady_WhenFlanked(±10, 0)`, dazu die Helfer. Der Verteidigungstest ist nicht gespiegelt, weil der Spiegel keine Spawn-Helfer für Feinde hat. +- `docs/production/DecisionLog.md`: `### D-127 | Entwurf | KI-Strang (…)` steht nach D-121 und vor „## Offene Punkte“, mit vier bewerteten Alternativen. D-114 hat einen **Statusvermerk** unter seiner Status-Zeile; der Originaltext bleibt unverändert. Kopfversion und Verlaufstabelle sind unberührt. +- `CHANGELOG.md`: ein Eintrag unter `[Unreleased]` / `### Behoben`, „(Refs #101, D-127)“. +- `reports/v8.6.0/issue-lauf-2026-10-17/09-issue-101a-flanked-staging-ring.md`: dieser Bericht. + +Guard-Dateien (`SnapshotGoldenBytesTests`, `CommandGoldenBytesTests`, +`SimRandomGoldenTests`, `Determinism10000Tests`) sind unberührt. +`CanonicalAiOutcomeTests.cs` ist unberührt. + +## Tests + +Umgebung: `DOTNET_ROOT="/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet"`. + +| Befehl | Ergebnis | +|---|---| +| `dotnet test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release` | **793/793 grün** (Basis 786 + 7 neue Fälle), 14 s | +| `python3 .github/scripts/check_docs.py` | Exit 0, „206 Markdown-Dateien und 5 Quality-JSONs geprüft“ (nach dem Anlegen dieses Berichts; davor 205). 13 bestehende Notices zu Status-Kopfzeilen, nicht von hier | +| `python3 .github/scripts/check_baseline_guard.py --self-test` | Exit 0, „Baseline guard self-test passed, including the required negative control.“ | + +Gezielte Belege: +- `CanonicalAiOutcomeTests.CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState` + ist grün und unverändert (Tick 2726, Endzustand `0x7AF32DBA56E393E2`). Die + Probe zeigt für Flanke 0 vor und nach der Änderung denselben Hash. +- `AiPersonalityTests`: alle grün, auch `EveryOpening_FlankAndFaction_FinishesTheMatch` + und `SeedsWithDifferentOpenings_PlayDifferentMatches`. Kein Budget geändert. + Entscheidungsticks der Matrix (24 Partien), vor → nach: + Spanne 1.987–2.982 → 2.208–2.871. Flanke +10 gegenüber Flanke 0 vorher + −458 bis −165 Ticks (stückweiser Angriff gegen wehrlosen Gegner), nachher + +11 bis +159 Ticks über alle Flanken. Gemessen durch vorübergehendes + Herausnehmen der einen Ringzeile im Worktree, Test, Wiederherstellen + (Wiederherstellung per `git diff` geprüft). +- Probe nach der Änderung: Flanke −10 Hold 166, kein Attack vor 1540, + entschieden 2760. Flanke +10 Hold 159, kein Attack vor 1540, entschieden 2739. + „Ausgerückt“ vor der Welle in beiden Fällen 0. + +**EditMode-Spiegel:** Die Quelldatei `Assets/Tests/EditMode/AI/SkirmishAiTests.cs` +wurde zusätzlich headless in einem Scratch-Projekt (NUnit 4.2.2, net8.0, dieselben +Sim-/AI-Quellen) kompiliert und ausgeführt: 12/12 grün (7 bestehende + 5 neue +Fälle). Das ist **kein Unity-Lauf**. + +**Nicht gelaufen:** +- Unity-EditMode-Tests im Editor (`Nova.AI.Tests`), keine CI-Lizenz und kein + Editor-Lauf in diesem Paket. +- Unity-PlayMode-Tests. +- Keine gespielte Runde. Belegt ist nur headless gegen einen passiven Gegner. + +## Entscheidung + +D-127 (Entwurf): Alternative 1, der Ring wächst um den Flankenbetrag und wird +weiter ab dem HQ gemessen. Abgelehnt: die Messung relativ zum Sammelpunkt (das +dokumentierte 11.000-Tick-Pendeln), die Flanke zu begrenzen (ändert D-114, und +die gekoppelte Ungleichung reißt beim nächsten Profilwechsel) und den Ring aus +dem tatsächlichen Sammelpunkt abzuleiten (gleichwertig, aber eine zweite +Herleitung ohne Gewinn). D-114 ist per Statusvermerk berichtigt: Die Konsequenz +„zwischen rund 2.000 und 3.000 Ticks“ maß den Defekt mit. + +## Nebenbefunde + +- **Die Docstring-Zeile in `CanonicalAiOutcomeTests.cs` (:58) ist veraltet.** + Dort steht „AiBehaviorId is r10.1E6E7AE3.“ Der Pin selbst bewegt sich nicht, + die Kennung ist jetzt aber r11. P-134 hatte die Zeile wie einen aktuellen + Zeiger berichtigt (r7 → r10). Die Datei gehört dem Netzstrang bzw. P-42 und + liegt nicht im Schreibumfang. Nicht geändert; Vorschlag: P-42 zieht die Zeile + mit nach. +- Gegen einen passiven Gegner ist der Defekt *schneller*. Geflankte Partien + entschieden bis zu 458 Ticks früher, weil die Einzelangriffe auf keinen + Widerstand treffen. Die headless Matrix hat den Fehler deshalb als „mehr + Vielfalt“ gezeigt statt als Defekt. Ein Gegner, der zurückschießt, fehlt in + `AiPersonalityTests`. +- `SkirmishGoalTests.GatheringHost` und mehrere Bestandstests rechnen den Ring + als `StagingDistanceCells + StagingToleranceCells`. Das stimmt, weil sie mit + neutraler Persönlichkeit laufen. Mit Flanke müsste dort der Betrag dazu. + +## Offene Fragen + +- Soll für den Defekt ein eigenes GitHub-Issue angelegt werden? Laut Paket + nur mit Freigabe; der PR trägt „Refs #101“. +- D-127 ist vorläufig. Die Endnummer wird beim Merge vergeben. + +## Commit-Titel + +`fix(ai): widen the wave commit ring by the staging flank offset` + +## PR-Beschreibung (Entwurf) + +**fix(ai): widen the wave commit ring by the staging flank offset** + +Refs #101 (Vorbedingung, Paket P-101a). Gestapelt auf #134 (`feat/134-harvester-born-harvesting`). + +**Was:** `SkirmishAiSystem.IsCommittedToTheWave` misst den Ring jetzt mit +`StagingDistanceCells + |Personality.FlankOffsetCells| + StagingToleranceCells` +ab dem HQ. `AiBehaviorId` r11 (`r11.1E6E7AE3`), Profil-Hash unverändert. + +**Warum:** Der D-114-Flankenversatz ±10 legt den Sammelpunkt auf Chebyshev 22 +vom HQ, der Ring maß 16. Jede ankommende Einheit galt als ausgerückt: kein +`Hold`, Einzelangriffe ab Tick 1.100/1.120 statt der vollen Welle ab 1.540, +und `DefendHome` fand keine Wartenden. Das betrifft zwei Drittel der +Menüpartien. Messung relativ zum Sammelpunkt ist bewusst verworfen (siehe +Docstring und D-127). + +**Betroffen:** `Scripts/AI/SkirmishAiSystem.cs`, `Scripts/AI.Data/AiBehaviorId.cs`, +`SkirmishAiTests` (Pin), `SkirmishGoalTests` (+3 Tests, 7 Fälle), EditMode-Spiegel +(+2 Tests, 5 Fälle), `DecisionLog` (D-127-Entwurf, Statusvermerk D-114), `CHANGELOG`. + +**Baselines:** `CanonicalAiOutcomeTests` unverändert grün (neutrale +Persönlichkeit ohne Flanke: Tick 2726, `0x7AF32DBA56E393E2`). Kein Guard +berührt. Geflankte Partien entscheiden anders. Die +`AiPersonalityTests`-Matrix verschiebt sich von 1.987–2.982 auf 2.208–2.871 +Ticks und bleibt grün. + +**Changelog:** `[Unreleased]` / Behoben: „Die KI sammelt auch am seitlich +versetzten Sammelpunkt (Refs #101, D-127).“ + +**Nachweise:** +- `dotnet test tools/Nova.SimRunner.Tests -c Release`: 793/793 grün. +- `check_docs.py`: Exit 0. `check_baseline_guard.py --self-test`: Exit 0. +- Die neuen Tests waren vor der Änderung rot (6 von 10 im Filter, die + Flanke-0-Kontrolle grün). + +**Nicht gelaufen:** Unity-EditMode und PlayMode (keine CI-Lizenz, kein +Editor-Lauf). Die Spiegelquelle ist nur headless mit NUnit kompiliert und +ausgeführt (12/12), das ersetzt den Unity-Lauf nicht. + +**Nicht gespielt.** Grund: headless Paket ohne Editor. Ersatznachweis: die +Goal-Aufzeichnung über `IAiGoalObserver` (Hold statt Attack, Committed 0 vor +der Welle, DefendHome am HQ) und die Personality-Matrix. Restrisiko: Wie die +volle Welle vom versetzten Sammelpunkt gegen einen zurückschießenden Menschen +wirkt, ist ungemessen. Ein Ring von 26 lässt ausrückende Einheiten etwas länger +als „sammelnd“ gelten (dieselbe Spanne von vier Feldern hinter dem Sammelpunkt +wie ungeflankt). Nicht spielerisch abgenommen, kein Meilenstein-Nachweis. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/reports/v8.6.0/issue-lauf-2026-10-17/09b-review-1-issue-101a.md b/reports/v8.6.0/issue-lauf-2026-10-17/09b-review-1-issue-101a.md new file mode 100644 index 0000000..dffdaa8 --- /dev/null +++ b/reports/v8.6.0/issue-lauf-2026-10-17/09b-review-1-issue-101a.md @@ -0,0 +1,143 @@ +--- +issue: 101 +paket: P-101a +reviewer: opus +runde: 1 +verdict: APPROVED +datum: 2026-10-17 +--- +# Review Runde 1: #101 (P-101a), der Commit-Ring wächst um den Flankenversatz + +Unabhängiges Read-only-Review nach AGENTS.md §3. Geprüft wurde der unkommittierte +Stand im Worktree `fix-101a` (Branch `fix/101-flanked-staging-commit-ring`, +gestapelt auf `feat/134-harvester-born-harvesting` @ `dafc7db`). Ich habe keine +Projektdatei geändert. Geschrieben habe ich nur diesen Bericht und eine +Scratch-Kopie unter `scratchpad/review101a/` für die Negativkontrolle. + +## Was geprüft wurde + +- `git status --short` und `git diff dafc7db` vollständig: 7 geänderte Dateien, + dazu der untrackte Builder-Bericht. `git diff --check` sauber. Abgeglichen mit + Issue #101, Paketspezifikation P-101a und der Triage. +- Guard-Dateien (`SnapshotGoldenBytesTests`, `CommandGoldenBytesTests`, + `SimRandomGoldenTests`, `Determinism10000Tests`) und `CanonicalAiOutcomeTests.cs`: + kein Diff gegen `dafc7db`. + +## Ursache und Fix am Code + +- `GetStagingCell` (`SkirmishAiSystem.cs:966-992`) verschiebt die Zelle um + `(-dy·flank/span, dx·flank/span)`. `IsCommittedToTheWave` (`:1226-1236`) maß + vorher `StagingDistanceCells + StagingToleranceCells` = 16. Auf der kanonischen + Karte liegt die geflankte Zelle bei (117,97) bzw. (97,117), Chebyshev 22 vom + HQ (119,119). Die Ursache stimmt. +- Der Fix bleibt HQ-relativ. Der Ring ist jetzt + `StagingDistanceCells + |FlankOffsetCells| + StagingToleranceCells`. Die + Schranke im Docstring habe ich nachgerechnet. Der Grundversatz ist je Achse + höchstens `distance`, die Flanke je Achse höchstens `|flank|`, weil + `|dx|,|dy| ≤ span` gilt. Clamp ist 1-Lipschitz und lässt die HQ-Zelle fest, + also ist der Abstand höchstens `distance + |flank|`. Auf der kanonischen Karte + wird die Schranke genau erreicht (22). Damit bleibt dieselbe Toleranz von vier + Feldern wie ungeflankt. +- **Determinismus:** nur `int`-Arithmetik, `Math.Abs` auf {−10, 0, 10}, kein + Überlauf, keine Iteration, keine float-Werte. `Personality` ist eine + Konstruktionskonstante und liegt nicht im Zustand. +- **Nebenwirkung, geprüft:** Der Ring speist das Wellenzählen und + `ResolveGoal`. Dort hängen `DefendHome` und `IsFitToMarch` davon ab. + Ungeflankt ist das Verhalten bitgleich. Geflankt gelten Einheiten bis 26 + Felder als sammelnd. Auf der diagonalen Karte wächst der Chebyshev-Abstand + beim Ausrücken genauso wie ungeflankt. Ein Pendeln wie in V002 zeigen weder + die Matrix noch die neuen Tests. + +## Selbst gemessen + +| Befehl | Ergebnis | +|---|---| +| `dotnet test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release` (Worktree) | **793/793 grün**, 17 s | +| `python3 .github/scripts/check_docs.py` | Exit 0, „206 Markdown-Dateien und 5 Quality-JSONs geprüft“ | +| `python3 .github/scripts/check_baseline_guard.py --self-test` | Exit 0, „passed, including the required negative control“ | +| **Negativkontrolle:** Scratch-Kopie der Quellen, nur die Ringzeile auf den alten Stand gesetzt, voller Lauf | **6 rot / 787 grün**: alle ±10-Fälle der drei neuen Tests. Die Flanke-0-Kontrolle, der r11-Pin und `CanonicalAiOutcomeTests` bleiben grün | + +Fehlermeldungen ohne Fix: `flank 10, tick 1120: 6 unit(s) counted as already +out`, `flank -10, tick 1100: 1 unit(s) …`, `unit 1033 was sent to attack at tick +1120, before the first wave was ready at tick 1540` (−10: unit 1038, Tick 1100). +Der Verteidigungstest scheitert mit „the wave marched before anybody held“. +Alle sechs Fehler haben also die richtige Ursache. + +Die Matrix aus `EveryOpening_FlankAndFaction_FinishesTheMatch` habe ich in beiden +Bäumen aus den `[personality]`-Zeilen gelesen: +- Mit Fix: 2.208–2.871. +- Ohne Fix: 1.987–2.982. Das untere Ende ist Alliance Rush +10. +- Flanke +10 gegenüber 0 ohne Fix: 165 bis 458 Ticks früher. +- Mit Fix liegen alle Flanken 11 bis 159 Ticks hinter Flanke 0. + +Alle Zahlen in D-127, im D-114-Statusvermerk und im Bericht stimmen mit dieser +Messung überein. `CanonicalAiOutcomeTests` (Tick 2726, `0x7AF32DBA56E393E2`) +ist in beiden Bäumen grün. + +Nicht selbst nachgemessen habe ich: +- die Hold- und Attack-Zählungen 171, 91, 159, 166 und 177 aus der Builder-Probe +- den Anteil „2.000 von 3.000 Seeds geflankt“ + +Die Testmeldungen stützen die Zählungen qualitativ. + +## Tests, Pins, Doku + +- **Neue Tests:** 3 Tests mit 7 Fällen in `SkirmishGoalTests`. Sie halten fest: + - Die Vorbedingung: Die Zelle liegt außerhalb des alten Rings und innerhalb + von `12 + |flank|`. + - `Committed == 0` vor der ersten Welle und mindestens ein `Hold`. + - Kein `Attack` vor `WaveReady`, mit Flanke 0 als Kontrolle. + - Ein Angriff am HQ erzeugt `DefendHome` und kein `Attack`. + + Ohne den Fix sind sie rot, siehe oben. +- **EditMode-Spiegel:** Signatur und Typen habe ich gegen + `SkirmishAiSystem`-Konstruktor, `AiFactionProfile.WithPersonality` und die + asmdef-Referenz `Nova.AI.Data` geprüft. Sie passen. Ausgeführt habe ich den + Spiegel nicht. Die Behauptung des Builders „12/12 headless“ habe ich nicht + nachvollzogen. Im Bericht und im PR-Text ist er korrekt als „kein Unity-Lauf“ + ausgewiesen. +- **AiBehaviorId:** `Revision` 10→11. Der r11-Absatz folgt dem r10-Muster. Der + Profil-Hash bleibt `1E6E7AE3`, weil keine Profilzahl dazukommt. Der Pin in + `SkirmishAiTests` steht auf `r11.1E6E7AE3`, mit Kommentar. +- **DecisionLog:** + - `### D-127 | Entwurf | …` steht nach D-121 und vor „## Offene Punkte“. + - Die Status-Zeile ist wie gefordert. Kontext, vier bewertete Alternativen + (die drei geforderten und eine Ableitungsvariante), Entscheidung, Begründung + und Konsequenzen sind vorhanden. + - D-114 ist nur per eingefügtem Statusvermerk berichtigt, der Originaltext ist + unverändert. + - Kopfversion und Verlaufstabelle sind nicht berührt. +- **CHANGELOG:** Der Eintrag steht unter `[Unreleased]` / `### Behoben` + (Zeile 442) mit „(Refs #101, D-127)“. +- **Bericht:** Er ist ehrlich. Das Nicht-Gelaufene ist vollständig genannt: + Unity EditMode und PlayMode, keine gespielte Runde, keine CI. Dazu kommen + Restrisiko und „nicht spielerisch abgenommen“. Das vorübergehende Herausnehmen + der Ringzeile im Worktree ist offen genannt, und der Endstand enthält die + Zeile. + +## Befunde + +Keiner der Schwere hoch oder mittel. + +1. **niedrig:** `tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs:58` sagt + weiter „AiBehaviorId is r10.1E6E7AE3“. Die Datei liegt außerhalb des + Schreibumfangs. Der Builder nennt das selbst als offene Frage. Vorschlag: P-42 + zieht die Zeile beim nächsten Anfassen auf r11 nach, oder sie wird als + Momentaufnahme („zuletzt bewegt bei r10“) formuliert. +2. **niedrig:** Im EditMode-Spiegel steht `using System;` hinter + `using System.Collections.Generic;`. Vorschlag: alphabetisch sortieren. +3. **niedrig:** Der CHANGELOG-Eintrag hat acht Zeilen, die Vorgabe sagte „eine + Zeile“. Er passt aber zum Stil der Nachbareinträge. Vorschlag: so lassen oder + auf zwei bis drei Zeilen kürzen. +4. **niedrig:** Die Probezahlen (Hold- und Attack-Zählungen, 2.000/3.000 Seeds) + habe ich nicht unabhängig nachgemessen. Vorschlag: Die Probe als Testausgabe + oder Skript nachvollziehbar machen, falls die Zahlen in D-127 verbindlich + zitiert bleiben sollen. + +## Urteil + +**APPROVED.** Die Ursache ist am Code belegt. Der Fix ist minimal, misst weiter +ab dem HQ und ist deterministisch. Die neuen Tests halten das Verhalten fest und +sind ohne Fix nachweislich rot. Die Pins sind konsistent, und alle Zahlen habe +ich selbst gemessen und bestätigt. Offen bleiben nur die im PR ausgewiesenen +Punkte: Unity-Lauf, gespielte Runde und CI. diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs index 1faac6b..1bdeb52 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs @@ -628,7 +628,12 @@ public void AiBehaviorId_TracksWhichAiThisIs() // harvesters get a Harvest intent, no new number. The canonical // outcome moved in the same PR, but from the production change // underneath — measured on the same head with and without r10. - Assert.That(AiBehaviorId.Value, Is.EqualTo("r10.1E6E7AE3"), + // + // r11 (#101, D-127) moves the revision only as well: the commit + // ring grows by the personality's flank, no number is added. The + // neutral personality has no flank, so the canonical outcome stays + // where r10 left it; only flanked matches decide differently. + Assert.That(AiBehaviorId.Value, Is.EqualTo("r11.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..e6885d9 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs @@ -787,6 +787,189 @@ public void AWoundedUnitThatIsAlreadyHomeDefendsInsteadOfHolding() Assert.That(seen, Is.True, "the subject was never judged"); } + // ================================================================ + // The flanked staging cell (r11, #101, D-127) + // + // D-114 shifts the staging cell sideways by up to ten cells. The ring + // that tells "still gathering" from "already out" was measured from + // the headquarters with the UNSHIFTED distance, so the shifted cell + // fell outside it: every unit that arrived there counted as committed, + // no unit was ever told to hold, and the army walked off piecemeal + // long before its wave was full — in two of three menu matches. + // ================================================================ + + /// + /// THE FLANKED STAGING CELL IS INSIDE THE RING. A unit that arrives + /// there is reported under , and until the + /// first wave marches no unit counts as already out. + /// + /// The first assertion is the precondition, not decoration: it proves + /// the scene puts the staging cell past the unflanked ring, which is + /// exactly the geometry of #101. Without it the test could pass on a + /// map where the flank happens not to matter. + /// + /// + [TestCase(10)] + [TestCase(-10)] + public void FlankedStagingCellIsInsideTheRing_AndProducesHold(int flank) + { + AiProfile shipped = AiProfiles.Ms1Canonical; + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = FlankedHost(flank, observer, out int hqX, out int hqY); + uint firstWave = RunToFirstWave(host, observer); + Assert.That(firstWave, Is.Not.Zero, $"flank {flank}: the first wave never marched"); + + AiArmyGoal army = FirstEngagingArmy(observer); + int stagingDistance = Math.Max(Math.Abs(army.StagingCellX - hqX), Math.Abs(army.StagingCellY - hqY)); + Assert.That(stagingDistance, Is.GreaterThan(shipped.StagingDistanceCells + shipped.StagingToleranceCells), + $"flank {flank}: the staging cell ({army.StagingCellX},{army.StagingCellY}) is inside the " + + "unflanked ring, so this scene does not reproduce #101"); + Assert.That(stagingDistance, Is.LessThanOrEqualTo(shipped.StagingDistanceCells + Math.Abs(flank)), + $"flank {flank}: the flank moved the staging cell further than its own size"); + + int holds = 0; + for (int i = 0; i < observer.Army.Count; i++) + { + if (observer.Army[i].Tick >= firstWave || !observer.Army[i].Goal.Engages) continue; + Assert.That(observer.Army[i].Goal.Committed, Is.Zero, + $"flank {flank}, tick {observer.Army[i].Tick}: {observer.Army[i].Goal.Committed} unit(s) " + + "counted as already out before any wave marched — the flanked staging cell lies " + + "outside the commit ring"); + } + for (int i = 0; i < observer.Units.Count; i++) + { + if (observer.Units[i].Tick < firstWave && observer.Units[i].Goal.Goal == GoalKind.Hold) holds++; + } + Assert.That(holds, Is.GreaterThan(0), + $"flank {flank}: not one unit was told to hold at the staging cell before the first wave"); + } + + /// + /// NO ATTACK BEFORE THE WAVE IS READY. Before the first wave marches, + /// every unit gathers; the first is the + /// wave itself. Flank 0 runs as the control: the unflanked cell was + /// always inside the ring and must stay that way. + /// + [TestCase(10)] + [TestCase(-10)] + [TestCase(0)] + public void NoAttackBeforeWaveReady_WhenFlanked(int flank) + { + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = FlankedHost(flank, observer, out _, out _); + uint firstWave = RunToFirstWave(host, observer); + Assert.That(firstWave, Is.Not.Zero, $"flank {flank}: the first wave never marched"); + + int attacksAtTheWave = 0; + for (int i = 0; i < observer.Units.Count; i++) + { + UnitEntry entry = observer.Units[i]; + if (entry.Goal.Goal != GoalKind.Attack) continue; + Assert.That(entry.Tick, Is.GreaterThanOrEqualTo(firstWave), + $"flank {flank}: unit {entry.Goal.EntityRaw} was sent to attack at tick {entry.Tick}, " + + $"before the first wave was ready at tick {firstWave}"); + if (entry.Tick == firstWave) attacksAtTheWave++; + } + Assert.That(attacksAtTheWave, Is.GreaterThan(0), + $"flank {flank}: the wave was reported ready at tick {firstWave} and nobody marched"); + } + + /// + /// THE GATHERERS AT A FLANKED STAGING CELL STILL DEFEND THE BASE. + /// This is what the ring is for beyond the wave: DefendHome + /// only takes units that are not committed, so a staging cell outside + /// the ring sent every waiting unit to the ENEMY when the own + /// headquarters came under attack. + /// + [TestCase(10)] + [TestCase(-10)] + public void FlankedGatherersStillBreakOffToDefendTheBase(int flank) + { + AiProfile shipped = AiProfiles.Ms1Canonical; + var observer = new RecordingObserver(); + SkirmishAiTests.AiHost host = FlankedHost(flank, observer, out int hqX, out int hqY); + + // Forward to the first unit standing at the staging cell, while + // the wave is still gathering. + int budget = SkirmishAiTests.EndToEndBudgetTicks; + bool holding = false; + while (budget-- > 0 && !holding) + { + host.Step(); + for (int i = observer.Units.Count - 1; i >= 0 && !holding; i--) + { + if (observer.Units[i].Goal.Goal == GoalKind.Hold) holding = true; + } + Assert.That(AnyWaveReady(observer), Is.False, + $"flank {flank}: the wave marched before anybody held, so there is nothing to break off"); + } + Assert.That(holding, Is.True, $"flank {flank}: no unit ever held at the staging cell"); + Assert.That(CountCombatUnits(host, AiSlot), Is.GreaterThanOrEqualTo(shipped.AttackSquadThreshold), + $"flank {flank}: the army is under its squad threshold, so no goal is handed out at all"); + + 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; + Assert.That(goal.Goal, Is.Not.EqualTo(GoalKind.Attack), + $"flank {flank}: unit {goal.EntityRaw} marched on the enemy while its own base was " + + "under attack and no wave had left"); + if (goal.Goal == GoalKind.DefendHome) defenders++; + } + Assert.That(defenders, Is.GreaterThan(0), + $"flank {flank}: the headquarters is under attack and not one gatherer broke off"); + } + + /// The canonical match with a Balanced personality whose only deviation is the flank. + private static SkirmishAiTests.AiHost FlankedHost( + int flank, RecordingObserver observer, out int hqX, out int hqY) + { + AiPersonality personality = AiPersonality.Create(AiOpening.Balanced, flank, layoutVariant: 0); + SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch( + SkirmishAiTests.Seed, goalObserver: observer, personality: personality); + Assert.That(TryHqCell(host, AiSlot, out hqX, out hqY), Is.True); + return host; + } + + /// Steps until the army reports its first ready wave; returns that tick (0 = never). + private static uint RunToFirstWave(SkirmishAiTests.AiHost host, RecordingObserver observer) + { + int budget = SkirmishAiTests.EndToEndBudgetTicks; + while (budget-- > 0 && !host.Victory.IsDecided) + { + host.Step(); + for (int i = observer.Army.Count - 1; i >= 0; i--) + { + AiArmyGoal army = observer.Army[i].Goal; + if (army.Engages && army.WaveReady) return observer.Army[i].Tick; + } + } + return 0; + } + + private static bool AnyWaveReady(RecordingObserver observer) + { + for (int i = 0; i < observer.Army.Count; i++) + { + if (observer.Army[i].Goal.Engages && observer.Army[i].Goal.WaveReady) return true; + } + return false; + } + + private static AiArmyGoal FirstEngagingArmy(RecordingObserver observer) + { + for (int i = 0; i < observer.Army.Count; i++) + { + if (observer.Army[i].Goal.Engages && observer.Army[i].Goal.StagingCellX >= 0) return observer.Army[i].Goal; + } + Assert.Fail("the army never engaged with a resolved staging cell"); + return default; + } + // ---------------------------------------------------------------- // Deterministic read helpers (ascending entity index) // ----------------------------------------------------------------