Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 126 additions & 5 deletions Assets/Tests/EditMode/AI/SkirmishAiTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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));
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
}
}
16 changes: 15 additions & 1 deletion Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
/// </para>
/// <para>
/// 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
/// <c>StagingDistanceCells + StagingToleranceCells</c>; 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
/// <c>StagingDistanceCells + |FlankOffsetCells| + StagingToleranceCells</c>,
/// still around the HQ. No number moved, so <see cref="ProfileHash"/> 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.
/// </para>
/// </summary>
public const int Revision = 10;
public const int Revision = 11;

/// <summary>
/// Hash over every value of the shipped profile. Domain-separated like
Expand Down
30 changes: 27 additions & 3 deletions Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <c>AiPersonalityTests.FlankedStagingCells_AreInsideTheGridAndWalkable</c>.
/// The commit ring in <see cref="IsCommittedToTheWave"/> grows by the
/// same offset, so the shifted cell stays a place where units WAIT
/// (r11, D-127).
/// </para>
/// <para>
/// Integer division truncates, which is deterministic and identical on
Expand Down Expand Up @@ -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
/// <c>StagingDistanceCells + StagingToleranceCells</c>, so where
/// exactly a unit stands while it waits does not matter.
/// <c>StagingDistanceCells + |FlankOffsetCells| + StagingToleranceCells</c>,
/// so where exactly a unit stands while it waits does not matter.
/// </para>
/// <para>
/// THE FLANK IS PART OF THE RING (r11, #101, D-127). A personality
/// shifts the staging cell sideways by
/// <see cref="AiPersonality.FlankOffsetCells"/>, 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 <c>|flank| * max(|dx|, |dy|) / span</c> cells at most, and
/// span IS that maximum, so in Chebyshev distance the shifted cell is
/// never further than <c>StagingDistanceCells + |flank|</c> 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.
/// </para>
/// </summary>
private bool IsCommittedToTheWave(in UnitState unit, int hqCellX, int hqCellY)
Expand All @@ -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;
}

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading