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