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)
// ----------------------------------------------------------------