diff --git a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs
index e27904e..18d9904 100644
--- a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs
+++ b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs
@@ -63,6 +63,47 @@ public static class AiBehaviorId
/// behaviour journal V003.
///
///
+ /// NOT bumped either for naming those branches .
+ /// Same four conditions, same order, same orders out; the canonical
+ /// match decides on the same tick with the same end state and a lab
+ /// run's artifacts came out byte-identical bar the measured runtime.
+ /// The refactor deliberately adds NO profile field — priorities in the
+ /// profile would move , and with it this very
+ /// identifier, which would have made the neutrality unprovable in the
+ /// artifacts it is printed into. A goal module that earns an off switch
+ /// brings one in the pull request that gives it a rule.
+ ///
+ ///
+ /// r8 IS THAT PULL REQUEST, and the identifier moves twice over: the
+ /// revision here because decisions change, and
+ /// because the rule ships with the off switch
+ /// that makes it measurable one-sided at all (finding M001).
+ /// DefendHome is the first goal to carry a rule of its own — the
+ /// units still waiting in the staging ring break off and walk home when
+ /// a visible armed enemy comes within
+ /// of their headquarters. What
+ /// it fixes is a defect as old as the staging cell itself (r3), not one
+ /// r6 introduced: a unit that has arrived is deliberately given no
+ /// order, so it hangs entirely on an auto-acquisition that reaches six
+ /// or seven cells while the staging cell sits twelve from the base.
+ ///
+ ///
+ /// TWO CORRECTIONS WENT INTO r8 BEFORE IT SHIPPED, both found by
+ /// reviewing the claim rather than the code, and neither moves
+ /// because neither adds a number. First: the
+ /// argument "the destination is static, so the suppression swallows
+ /// every repeat" only holds WHILE the defender walks — arriving clears
+ /// the standing order the suppression compares against, so the
+ /// headquarters cell went out again every cadence, one intent per
+ /// cadence for the whole siege, with the standing defenders flipped back
+ /// into movement each time. DefendHome now falls silent once
+ /// home, the way Hold does at the staging cell. Second: the goal
+ /// asked not to be retreating, which could only ever exclude a wounded
+ /// unit that had ALREADY ARRIVED — and arriving ends the retreat by the
+ /// rule right above it. Those units fell to Hold and stood twelve
+ /// cells out while the base they had run to burned.
+ ///
+ ///
/// r5 fixes two defects found in the review of r3/r4, both of which
/// change decisions and therefore the end state: the wave now waits for
/// what production can still deliver instead of a fixed cap (a single
@@ -116,7 +157,7 @@ public static class AiBehaviorId
/// retried an illegal placement forever once the all-of gate shipped.
///
///
- public const int Revision = 7;
+ public const int Revision = 8;
///
/// Hash over every value of the shipped profile. Domain-separated like
@@ -167,6 +208,7 @@ public static ulong ComputeProfileHash(AiProfile profile)
writer.WriteInt32(profile.RetreatHealthPercent);
writer.WriteInt32(profile.RetreatDangerCells);
writer.WriteInt32(profile.WaveStrengthPoints);
+ writer.WriteInt32(profile.DefendHomeCells);
return writer.Digest();
}
}
diff --git a/Assets/_Project/Scripts/AI.Data/AiProfile.cs b/Assets/_Project/Scripts/AI.Data/AiProfile.cs
index e727550..68752d3 100644
--- a/Assets/_Project/Scripts/AI.Data/AiProfile.cs
+++ b/Assets/_Project/Scripts/AI.Data/AiProfile.cs
@@ -176,6 +176,52 @@ namespace Nova.AI.Data
///
public int RetreatDangerCells { get; }
+ ///
+ /// How near a visible ARMED enemy has to come to the own headquarters
+ /// before the units still waiting in the staging ring break off and
+ /// walk home to fight. 0 means off.
+ ///
+ /// WHY THE RULE HAS TO EXIST. A unit that has ARRIVED at the staging
+ /// cell is given no order at all — deliberately, because an order per
+ /// cadence to a standing unit is intent churn without a change of
+ /// behaviour. It therefore depends entirely on the D-087
+ /// auto-acquisition, and that reaches exactly as far as its weapon:
+ /// six cells for Legion infantry, seven for an Alliance rifleman. The
+ /// staging cell sits — twelve — from
+ /// the headquarters. An attacker at the base is outside every one of
+ /// those ranges, so the waiting units do not ignore it: they cannot see
+ /// it. Measured in the canonical match: the Legion headquarters takes
+ /// 327 hits over 766 ticks while its own units stand a median of
+ /// thirteen cells away under Hold, and not one of them is attacking.
+ ///
+ ///
+ /// WHY TEN. It is the radius B003 was measured with, and it sits
+ /// between (eight, so inside the base)
+ /// and the staging ring (sixteen, so a defender is not summoned by a
+ /// skirmish at the gathering point).
+ ///
+ ///
+ /// THERE IS NO SECOND RADIUS. A hysteresis value — "stay home until the
+ /// enemy is fourteen cells out" — is the obvious second number and is
+ /// deliberately absent: both destinations are STATIC cells, a defender
+ /// that has arrived stops being ordered at all, and the re-issue
+ /// suppression swallows the rest, so whether the trigger flutters is a
+ /// question for the intent column and not for a precaution. That is the
+ /// correction over the discarded DefendBase (journal V002), which
+ /// handed the WHOLE army a new destination every cadence and paid 23 %
+ /// more intents for it.
+ ///
+ ///
+ /// THE STATIC CELL IS NOT ENOUGH ON ITS OWN, and the first version of
+ /// this rule assumed it was. The suppression compares the STANDING
+ /// order, and arriving clears it — so the silence a defender needs once
+ /// it is home is written out in SkirmishAiSystem.HasArrivedAtHome
+ /// rather than falling out of the geometry. Measured before that
+ /// existed: one move intent per cadence for as long as the siege ran.
+ ///
+ ///
+ public int DefendHomeCells { get; }
+
// ---- target scoring ----
//
// Four weights over ONE integer score, no scalar quality function:
@@ -219,7 +265,8 @@ public AiProfile(
int stagingToleranceCells,
int retreatHealthPercent,
int retreatDangerCells,
- int waveStrengthPoints)
+ int waveStrengthPoints,
+ int defendHomeCells)
{
ProfileId = profileId ?? string.Empty;
DecisionTickInterval = decisionTickInterval;
@@ -240,6 +287,7 @@ public AiProfile(
RetreatHealthPercent = retreatHealthPercent;
RetreatDangerCells = retreatDangerCells;
WaveStrengthPoints = waveStrengthPoints;
+ DefendHomeCells = defendHomeCells;
}
///
@@ -272,7 +320,8 @@ public bool Equals(AiProfile other) =>
&& StagingToleranceCells == other.StagingToleranceCells
&& RetreatHealthPercent == other.RetreatHealthPercent
&& RetreatDangerCells == other.RetreatDangerCells
- && WaveStrengthPoints == other.WaveStrengthPoints;
+ && WaveStrengthPoints == other.WaveStrengthPoints
+ && DefendHomeCells == other.DefendHomeCells;
public override bool Equals(object obj) => obj is AiProfile other && Equals(other);
@@ -299,6 +348,7 @@ public override int GetHashCode()
hash = (hash * 397) ^ RetreatHealthPercent;
hash = (hash * 397) ^ RetreatDangerCells;
hash = (hash * 397) ^ WaveStrengthPoints;
+ hash = (hash * 397) ^ DefendHomeCells;
return hash;
}
}
diff --git a/Assets/_Project/Scripts/AI.Data/AiProfiles.cs b/Assets/_Project/Scripts/AI.Data/AiProfiles.cs
index e323166..f1e9156 100644
--- a/Assets/_Project/Scripts/AI.Data/AiProfiles.cs
+++ b/Assets/_Project/Scripts/AI.Data/AiProfiles.cs
@@ -139,7 +139,34 @@ public static class AiProfiles
// 0 switches the rule off and restores the head count exactly
// (finding M001: a behaviour without an off setting cannot be
// measured one-sided).
- waveStrengthPoints: 1200);
+ waveStrengthPoints: 1200,
+ // Defend home (r8). Ten cells around the own headquarters: inside
+ // the base, and short of the staging ring at sixteen, so a skirmish
+ // at the gathering point does not summon anybody.
+ //
+ // THE RULE EXISTS BECAUSE OF A MEASURED HOLE, not a hunch. A unit
+ // that has arrived at the staging cell is given no order at all —
+ // on purpose, an order per cadence to a standing unit is churn — so
+ // it depends entirely on the D-087 auto-acquisition, and that
+ // reaches six cells for Legion infantry and seven for an Alliance
+ // rifleman. The staging cell sits twelve cells from the base. An
+ // attacker at the headquarters is outside all of it. In the
+ // canonical match the Legion headquarters takes 327 hits over 766
+ // ticks while its own units stand a median of thirteen cells away
+ // under Hold, and not one of them attacks.
+ //
+ // The wave is INTERRUPTED, not released: the destination is the own
+ // headquarters, a static cell, so the defenders walk toward the
+ // fight rather than away from it, the re-issue suppression swallows
+ // the repeats on the way, and a defender that has arrived is not
+ // ordered any more at all (the suppression alone cannot carry that
+ // — arriving clears the standing order it compares against). That is
+ // the correction over the discarded DefendBase (V002), which handed
+ // the whole army a moving target every cadence and paid 23 % more
+ // intents for it.
+ //
+ // 0 switches the rule off and restores r7 exactly (M001).
+ defendHomeCells: 10);
///
/// The old AiFactionProfile constructor defaults (power margin
@@ -173,6 +200,8 @@ public static class AiProfiles
retreatDangerCells: 8,
// Same reasoning again: there is no "legacy" strength value, the
// wave counted heads. 0 is the off setting.
- waveStrengthPoints: 0);
+ waveStrengthPoints: 0,
+ // And no legacy defence value either — there was no defence rule.
+ defendHomeCells: 0);
}
}
diff --git a/Assets/_Project/Scripts/AI.Data/GoalKind.cs b/Assets/_Project/Scripts/AI.Data/GoalKind.cs
new file mode 100644
index 0000000..8d1fad9
--- /dev/null
+++ b/Assets/_Project/Scripts/AI.Data/GoalKind.cs
@@ -0,0 +1,127 @@
+namespace Nova.AI.Data
+{
+ ///
+ /// What one unit is trying to do in THIS decision — a name for a condition
+ /// and its effect, and nothing else.
+ ///
+ /// A GOAL IS NOT STATE. It is worked out fresh on every decision cadence
+ /// from the committed world and thrown away again; nothing stores it, and
+ /// therefore nothing has to serialize it. That is what keeps the skirmish AI
+ /// a pure function of the tick and the committed state after the goals were
+ /// named — the names describe the decision, they do not survive it.
+ ///
+ ///
+ /// It lives in this assembly rather than beside the rules because THREE
+ /// SURFACES HAVE TO AGREE ON THE WORDS: the simulation that picks a goal,
+ /// the lab that records which one was picked, and the panel that draws it.
+ /// Nova.AI.Data references Nova.Core and nothing else, so every one of them
+ /// can name a goal without pulling the simulation in behind it.
+ ///
+ ///
+ /// THE PRIORITY IS FIXED AND IT IS NOT THE NUMBER. When more than one
+ /// condition holds, the winner is decided by the order the tests are
+ /// written in SkirmishAiSystem.ResolveGoal, which is:
+ ///
+ ///
+ /// - — a wounded unit STILL RUNNING outranks
+ /// everything, or the pull-back could never reach the one unit it exists
+ /// for; one that has arrived is an ordinary waiting unit again
+ /// - — the base burning outranks gathering for
+ /// a wave that will leave it burning
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// THE NUMBERS ARE APPENDED, NEVER RENUMBERED, and that is why the two came
+ /// apart. A value is written into goals.ndjson as a bare integer;
+ /// renumbering to keep value order equal to priority order would silently
+ /// re-label every recorded run — the panel would print attack where
+ /// the file means hold, which is precisely the display error a
+ /// diagnostic tool must not have. Columns are appended in the artifacts for
+ /// the same reason, and a goal is a column.
+ ///
+ ///
+ /// The priority is fixed in code and not a profile value: priorities in the
+ /// profile would have moved AiProfile.ProfileHash while the first
+ /// step of this strand still had to be provably behaviour-neutral
+ /// (ROADMAP.md point 2). A module that needs an off setting brings one with
+ /// it, in the pull request that gives it a rule worth switching off —
+ /// is the first that did.
+ ///
+ ///
+ public enum GoalKind : byte
+ {
+ ///
+ /// No goal — the unit was not judged at all this decision, and for the
+ /// goal mask (Nova.AI.IAiGoalOverride) it means "leave this one to
+ /// the AI". Never the answer of the resolver: every combat unit the army
+ /// step looks at gets one of the five below.
+ ///
+ None = 0,
+
+ ///
+ /// Wounded, in danger, and pulling out: walk to the staging cell and
+ /// shoot at whoever is chasing. Outranks everything, because a rule that
+ /// cannot beat "you are out with the wave, keep going" can never pull
+ /// anybody back.
+ ///
+ Retreat = 1,
+
+ ///
+ /// Marching on the army's target: the shared attack target and the
+ /// shared destination. The wave is out, or this unit already is.
+ ///
+ Attack = 2,
+
+ ///
+ /// Standing at the staging cell with nothing to say. THE EFFECT IS
+ /// SILENCE, and that is the goal's whole content: a unit that is where
+ /// it belongs and gets told so again every cadence turns 23 actions per
+ /// minute into 40 without changing anything (behaviour journal V002).
+ ///
+ Hold = 3,
+
+ ///
+ /// Reinforcement on its way to the staging cell. No attack target while
+ /// it walks — an explicit order is released only by its target's death,
+ /// so aiming while not closing the distance silences the unit instead of
+ /// arming it (finding F001, journal V003).
+ ///
+ Advance = 4,
+
+ ///
+ /// The base is under attack and this unit was waiting to leave it:
+ /// break off, walk to the own headquarters, shoot at whoever is nearest.
+ /// Outranks — gathering for a wave that marches
+ /// away from a burning base is the defect this goal exists for — and
+ /// gives way to , because a unit too wounded to
+ /// fight and still running is no defender. One that has already made it
+ /// home is: arriving ends the retreat, so it defends like anybody else
+ /// standing in the ring.
+ ///
+ /// AND ONCE HOME IT SAYS NOTHING FURTHER. The destination is a static
+ /// cell, but that alone does not stop the order repeating — the re-issue
+ /// suppression reads the standing order, and arriving CLEARS the
+ /// standing order. A defender that has arrived therefore falls silent
+ /// explicitly, exactly the way is silent at the
+ /// staging cell; without it the headquarters cell went out again every
+ /// cadence for the whole siege.
+ ///
+ ///
+ /// ONLY UNITS STILL INSIDE THE STAGING RING. A wave that is already out
+ /// keeps marching: "units that are out are never called back" is the r3
+ /// rule that made a wave a wave, and recalling them is the V002 failure
+ /// mode with a new name.
+ ///
+ ///
+ /// THE DESTINATION IS A STATIC CELL — the headquarters, which does not
+ /// move all match — and not the enemy. That is the whole difference to
+ /// the discarded DefendBase: a moving destination meant a fresh
+ /// order every cadence for every unit, 23 % more intents and a worse
+ /// match (journal V002).
+ ///
+ ///
+ DefendHome = 5,
+ }
+}
diff --git a/Assets/_Project/Scripts/AI.Data/GoalKind.cs.meta b/Assets/_Project/Scripts/AI.Data/GoalKind.cs.meta
new file mode 100644
index 0000000..97a7aac
--- /dev/null
+++ b/Assets/_Project/Scripts/AI.Data/GoalKind.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5ff6e2a7b6a5198b0f46fcfdabbe1a71
diff --git a/Assets/_Project/Scripts/AI/AiFactionProfile.cs b/Assets/_Project/Scripts/AI/AiFactionProfile.cs
index a31374e..506feb5 100644
--- a/Assets/_Project/Scripts/AI/AiFactionProfile.cs
+++ b/Assets/_Project/Scripts/AI/AiFactionProfile.cs
@@ -80,7 +80,8 @@ public AiFactionProfile(string factionName, int targetPowerMargin = 30, int targ
stagingToleranceCells: shipped.StagingToleranceCells,
retreatHealthPercent: shipped.RetreatHealthPercent,
retreatDangerCells: shipped.RetreatDangerCells,
- waveStrengthPoints: shipped.WaveStrengthPoints);
+ waveStrengthPoints: shipped.WaveStrengthPoints,
+ defendHomeCells: shipped.DefendHomeCells);
}
/// Binds a faction name to a fully specified profile — the tuning path.
diff --git a/Assets/_Project/Scripts/AI/IAiGoalObserver.cs b/Assets/_Project/Scripts/AI/IAiGoalObserver.cs
new file mode 100644
index 0000000..fb50485
--- /dev/null
+++ b/Assets/_Project/Scripts/AI/IAiGoalObserver.cs
@@ -0,0 +1,208 @@
+using Nova.AI.Data;
+
+namespace Nova.AI
+{
+ /// Which rule the wave gate answered with this decision.
+ public enum WaveGateMode : byte
+ {
+ /// Waves are off (waveSize 1): every unit is its own wave.
+ Off = 0,
+
+ /// The threshold is a number of gathered units.
+ Count = 1,
+
+ /// The threshold is a sum of combat points (r6).
+ Strength = 2,
+ }
+
+ ///
+ /// What the army as a whole decided this cadence, with the numbers the
+ /// decision was made from.
+ ///
+ /// The numbers are the point. "The wave waits" explains nothing; "the ring
+ /// holds 1.060 of the 1.200 points it needs" says how far off it is and in
+ /// which direction the next unit moves it.
+ ///
+ ///
+ public readonly struct AiArmyGoal
+ {
+ /// False when the army does not act at all — below the squad threshold, or no committed team view.
+ public readonly bool Engages;
+
+ /// The scored target every marching unit shoots at; 0 when nothing enemy is visible.
+ public readonly uint TargetRaw;
+
+ /// Where the army walks; -1 while it does not act.
+ public readonly int MoveCellX;
+
+ /// See .
+ public readonly int MoveCellY;
+
+ /// Where reinforcements gather; -1 when waves are off or the army does not act.
+ public readonly int StagingCellX;
+
+ /// See .
+ public readonly int StagingCellY;
+
+ /// Whether what waits in the staging ring is enough for the wave to march.
+ public readonly bool WaveReady;
+
+ /// Which rule answered — the unit of measure of .
+ public readonly WaveGateMode WaveMode;
+
+ /// Living combat units inside the staging ring.
+ public readonly int Gathered;
+
+ /// Living combat units outside it — an earlier wave, never called back.
+ public readonly int Committed;
+
+ /// Summed combat points of the gathered units.
+ public readonly long GatheredStrength;
+
+ ///
+ /// What the ring has to hold before the wave marches — points under
+ /// , heads under
+ /// , 0 while waves are off. Already
+ /// capped by what production can still deliver, so the difference to
+ /// what is gathered is the honest distance to the march.
+ ///
+ public readonly long WaveThreshold;
+
+ ///
+ /// A visible armed enemy stands within AiProfile.DefendHomeCells
+ /// of the headquarters, so everyone still in the ring breaks off and
+ /// defends (r8). Always false while the rule is off.
+ ///
+ /// APPENDED, like every column before it — a reader of an older file
+ /// keeps reading the columns it has.
+ ///
+ ///
+ public readonly bool HomeThreatened;
+
+ public AiArmyGoal(
+ bool engages, uint targetRaw, int moveCellX, int moveCellY,
+ int stagingCellX, int stagingCellY, bool waveReady, WaveGateMode waveMode,
+ int gathered, int committed, long gatheredStrength, long waveThreshold,
+ bool homeThreatened)
+ {
+ HomeThreatened = homeThreatened;
+ Engages = engages;
+ TargetRaw = targetRaw;
+ MoveCellX = moveCellX;
+ MoveCellY = moveCellY;
+ StagingCellX = stagingCellX;
+ StagingCellY = stagingCellY;
+ WaveReady = waveReady;
+ WaveMode = waveMode;
+ Gathered = gathered;
+ Committed = committed;
+ GatheredStrength = gatheredStrength;
+ WaveThreshold = waveThreshold;
+ }
+ }
+
+ ///
+ /// What one unit was told to do this cadence, which goal said so, and the
+ /// measured quantities every goal condition compared against a profile
+ /// value.
+ ///
+ /// EVERY CONDITION IN THE GOAL CATALOGUE IS AN INTEGER COMPARISON, so the
+ /// distance to the next one is exact arithmetic rather than an estimate:
+ /// RetreatHealthPercent - HealthPercent is how much life a unit has
+ /// left before it turns, StagingToleranceCells - StagingDistanceCells
+ /// how many cells before it counts as arrived. That is what a panel can show
+ /// without repeating the rules in a second language and getting them subtly
+ /// wrong.
+ ///
+ ///
+ public readonly struct AiUnitGoal
+ {
+ /// The unit this is about.
+ public readonly uint EntityRaw;
+
+ /// The goal that won.
+ public readonly GoalKind Goal;
+
+ /// True when a goal mask named this unit — the goal was not the AI's own pick.
+ public readonly bool Forced;
+
+ /// The attack order that goes out; 0 means no attack intent is submitted.
+ public readonly uint AttackTargetRaw;
+
+ /// The move order that goes out; -1 means the unit is left where it walks.
+ public readonly int MoveCellX;
+
+ /// See .
+ public readonly int MoveCellY;
+
+ /// Health in percent of maximum — the left-hand side of the retreat rule.
+ public readonly int HealthPercent;
+
+ /// Chebyshev cells to the nearest visible ARMED enemy, or -1 when none is visible or the retreat rule is off.
+ public readonly int ThreatDistanceCells;
+
+ /// Chebyshev cells to the staging cell, or -1 when no staging cell is resolved.
+ public readonly int StagingDistanceCells;
+
+ /// Chebyshev cells to the own headquarters — the left-hand side of the staging-ring test.
+ public readonly int HomeDistanceCells;
+
+ public AiUnitGoal(
+ uint entityRaw, GoalKind goal, bool forced, uint attackTargetRaw,
+ int moveCellX, int moveCellY, int healthPercent,
+ int threatDistanceCells, int stagingDistanceCells, int homeDistanceCells)
+ {
+ EntityRaw = entityRaw;
+ Goal = goal;
+ Forced = forced;
+ AttackTargetRaw = attackTargetRaw;
+ MoveCellX = moveCellX;
+ MoveCellY = moveCellY;
+ HealthPercent = healthPercent;
+ ThreatDistanceCells = threatDistanceCells;
+ StagingDistanceCells = stagingDistanceCells;
+ HomeDistanceCells = homeDistanceCells;
+ }
+ }
+
+ ///
+ /// Watches the skirmish AI decide, without being able to change what it
+ /// decides.
+ ///
+ /// WHY AN OBSERVER AND NOT A RECORD ON THE SYSTEM. The AI is a pure function
+ /// of the tick and the committed state; a buffer of "what I decided last"
+ /// hanging off it would be exactly the memory the whole design avoids, and
+ /// the first thing a later rule would be tempted to read. A callback carries
+ /// the same information out to whoever asked for it and leaves nothing
+ /// behind.
+ ///
+ ///
+ /// WHAT THIS IS FOR. Until now the only way to see WHY a unit did something
+ /// was to re-implement the rules beside the recording and label the result
+ /// derived — and a diagnostic tool that shows a second, slightly different
+ /// set of rules is worse than one that shows none. The goal is now recorded
+ /// where it is decided.
+ ///
+ ///
+ /// THE SHIPPED GAME NEVER PASSES ONE, so the null check is the entire cost
+ /// in the delivered path, and everything the observer wants computed is
+ /// computed behind it.
+ ///
+ ///
+ public interface IAiGoalObserver
+ {
+ ///
+ /// The army's posture for this decision — reported even when the army
+ /// does not act, because "it does not act" is the answer one is looking
+ /// for at least as often as the other one.
+ ///
+ void OnArmyGoal(byte slot, uint tick, in AiArmyGoal army);
+
+ ///
+ /// One unit's goal for this decision. Called in the ascending entity
+ /// scan, only while the army acts — below the squad threshold no unit is
+ /// judged at all, which the army report has already said.
+ ///
+ void OnUnitGoal(byte slot, uint tick, in AiUnitGoal goal);
+ }
+}
diff --git a/Assets/_Project/Scripts/AI/IAiGoalObserver.cs.meta b/Assets/_Project/Scripts/AI/IAiGoalObserver.cs.meta
new file mode 100644
index 0000000..028d5bf
--- /dev/null
+++ b/Assets/_Project/Scripts/AI/IAiGoalObserver.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c17ce524c35cb04d04afcd004d4ff92d
diff --git a/Assets/_Project/Scripts/AI/IAiGoalOverride.cs b/Assets/_Project/Scripts/AI/IAiGoalOverride.cs
new file mode 100644
index 0000000..33ac884
--- /dev/null
+++ b/Assets/_Project/Scripts/AI/IAiGoalOverride.cs
@@ -0,0 +1,43 @@
+using Nova.AI.Data;
+
+namespace Nova.AI
+{
+ ///
+ /// A goal forced onto single units from outside — the "override" half of the
+ /// lab's admin panel.
+ ///
+ /// THE MASK IS AN INPUT, NOT A STATE, and the distinction is the whole
+ /// reason this is an interface and not a field on the AI. The host answers
+ /// "for this entity, goal X" before each decision; the AI reads the answer
+ /// exactly the way it reads its profile, picks, and forgets. Nothing is
+ /// remembered between cadences, no block is added beside the world, and the
+ /// system stays a pure function of the tick, the committed state and its
+ /// inputs. A sidecar would have been an owner decision (D-ID); this is not
+ /// one.
+ ///
+ ///
+ /// THE SHIPPED GAME NEVER PASSES ONE. MatchRunner constructs the AI
+ /// without it, the reference is null, and the null check is the only cost —
+ /// which is what makes "with the mask compiled in" byte-identical to
+ /// "before", and therefore measurable at all.
+ ///
+ ///
+ /// A run in which somebody intervened is NOT A MEASUREMENT. It says what the
+ /// AI could have done, never what it does; the lab marks such a run and
+ /// keeps it out of the archive.
+ ///
+ ///
+ public interface IAiGoalOverride
+ {
+ ///
+ /// The goal forced on this entity, or to
+ /// leave the decision to the AI.
+ ///
+ /// Called once per combat unit per decision cadence, in the ascending
+ /// entity scan. It must be a pure function of the caller's own state for
+ /// the run to stay reproducible: same tick, same entity, same answer.
+ ///
+ ///
+ GoalKind ResolveGoal(uint entityRaw);
+ }
+}
diff --git a/Assets/_Project/Scripts/AI/IAiGoalOverride.cs.meta b/Assets/_Project/Scripts/AI/IAiGoalOverride.cs.meta
new file mode 100644
index 0000000..597c185
--- /dev/null
+++ b/Assets/_Project/Scripts/AI/IAiGoalOverride.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7ac4128cf874d9d5d924d63446c281c4
diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
index 9935045..67349bf 100644
--- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
+++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
@@ -70,8 +70,11 @@ namespace Nova.AI
/// Barracks stands, infantry is queued up to
/// as funds allow;
/// (5) the army resolves a POSTURE (does it act at all, which target,
- /// which destination), then ONE ASSIGNMENT PER UNIT, then submission that
- /// groups units sharing an order into a single intent: at
+ /// which destination), then ONE GOAL PER UNIT out of the fixed priority
+ /// list in — Retreat, DefendHome,
+ /// Attack, Hold, Advance — whose effect is the unit's
+ /// orders, then
+ /// submission that groups units sharing an order into a single intent: at
/// living combat units
/// the army is sent toward the enemy start area, and the best scored
/// visible enemy receives an EXPLICIT AttackTarget intent from every unit.
@@ -138,6 +141,19 @@ private struct SiteInfo
private readonly FogOfWarSystem _fogOfWar;
private readonly VictorySystem _victory;
+ ///
+ /// Watches the decisions; null in the shipped game. Read-only from the
+ /// AI's side — an observer cannot change what is decided, and nothing it
+ /// is told is kept here.
+ ///
+ private readonly IAiGoalObserver _goalObserver;
+
+ ///
+ /// Goals forced from outside; null in the shipped game. An INPUT to the
+ /// decision, like the profile — never a memory of one.
+ ///
+ private readonly IAiGoalOverride _goalOverride;
+
public string Name => $"SkirmishAi_{_profile.FactionName}_P{_aiPlayerId}";
public byte AiPlayerId => _aiPlayerId;
@@ -147,6 +163,16 @@ private struct SiteInfo
/// the human host ingress — that is what keeps the AI on the canonical
/// intent path with an authority-assigned slot, sequence and target
/// tick.
+ ///
+ /// THE LAST TWO ARGUMENTS ARE OPTIONAL AND THE GAME PASSES NEITHER.
+ /// is told which goal each unit was
+ /// given, may name a goal for single
+ /// units before the decision is taken; both belong to the lab's admin
+ /// panel. They are constructor arguments rather than settable properties
+ /// so that neither can be swapped mid-match: the AI reads two references
+ /// that were fixed before the first tick, which is what lets the run
+ /// stay reproducible from its inputs.
+ ///
///
public SkirmishAiSystem(
byte aiPlayerId,
@@ -157,8 +183,12 @@ public SkirmishAiSystem(
ConstructionSystem construction,
ProductionSystem production,
FogOfWarSystem fogOfWar,
- VictorySystem victory)
+ VictorySystem victory,
+ IAiGoalObserver goalObserver = null,
+ IAiGoalOverride goalOverride = null)
{
+ _goalObserver = goalObserver;
+ _goalOverride = goalOverride;
_aiPlayerId = aiPlayerId;
_profile = profile;
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
@@ -186,7 +216,7 @@ public void ExecuteTick(Tick tick)
{
if (tick.Value % DecisionTickInterval != 0) return;
if (_victory.IsDecided) return;
- Decide();
+ Decide(tick.Value);
}
public void Shutdown()
@@ -197,7 +227,7 @@ public void Shutdown()
// The decision loop (pure function of the committed state)
// ------------------------------------------------------------------
- private void Decide()
+ private void Decide(uint tick)
{
FactionId faction = _economy.GetSlotFaction(_aiPlayerId);
ref readonly PlayerEconomyState eco = ref _economy.GetPlayerEconomy(_aiPlayerId);
@@ -473,33 +503,45 @@ private void Decide()
}
}
- // ---- (6) Army: resolve one posture for the army, one assignment
- // per unit, then submit the assignments grouped. See the three
- // steps below; the rules are exactly the ones the previous
- // whole-army block applied. ----
+ // Cells of the visible ARMED enemies, collected once per decision.
+ // A local list, not a field: the system stays a pure function of
+ // the committed state, and nothing survives the decision.
+ //
+ // IT IS COLLECTED BEFORE THE POSTURE, and it has to be. The posture
+ // now answers "is the base under attack", which is a question about
+ // this list — and gathering it inside the per-unit loop below, where
+ // it used to live, would have meant asking after the answer was
+ // needed.
+ //
+ // TWO RULES SHARE IT, SO TWO RULES OPEN IT. The gate used to read
+ // RetreatHealthPercent alone; leaving it that way would have let
+ // retreat-off switch the defence off in silence, and the lab's
+ // `retreat-off` candidate would then measure something other than
+ // its name.
+ List threatCells = null;
+ List threatRaws = null;
+ if (_profile.Profile.RetreatHealthPercent > 0 || _profile.Profile.DefendHomeCells > 0)
+ {
+ threatCells = new List();
+ threatRaws = new List();
+ CollectVisibleThreats(threatCells, threatRaws);
+ }
+
+ // ---- (6) Army: resolve one posture for the army, one GOAL per
+ // unit, then submit the resulting orders grouped. See the three
+ // steps below; the rules are the ones the previous whole-army
+ // block applied, plus the defence r8 adds to the catalogue. ----
ArmyPosture posture = ResolveArmyPosture(
- faction, barracksRaw, combatCount, combatUnits, hqCellX, hqCellY);
+ faction, barracksRaw, combatCount, combatUnits, hqCellX, hqCellY, threatCells);
+ if (_goalObserver != null) ReportArmyGoal(tick, in posture);
if (posture.Engages)
{
- // Cells of the visible ARMED enemies, collected once per
- // decision and only while the retreat rule is on. A local
- // list, not a field: the system stays a pure function of the
- // committed state, and nothing survives the decision.
- List threatCells = null;
- List threatRaws = null;
- if (_profile.Profile.RetreatHealthPercent > 0)
- {
- threatCells = new List();
- threatRaws = new List();
- CollectVisibleThreats(threatCells, threatRaws);
- }
-
var assignments = new List(combatUnits.Count);
for (int i = 0; i < combatUnits.Count; i++)
{
UnitState unit = combatUnits[i];
assignments.Add(ResolveUnitAssignment(
- combatRaws[i], in unit, in posture, hqCellX, hqCellY, threatCells, threatRaws));
+ combatRaws[i], in unit, in posture, hqCellX, hqCellY, threatCells, threatRaws, tick));
}
SubmitAssignments(assignments, combatUnits);
}
@@ -574,6 +616,48 @@ private struct ArmyPosture
/// own wave.
///
public bool WaveReady;
+
+ ///
+ /// The own headquarters cell — where a defender walks. Static for
+ /// the whole match, which is the property the rule is built on: a
+ /// destination that does not move produces ONE order and not a
+ /// stream of them.
+ ///
+ public int HomeCellX;
+
+ /// See .
+ public int HomeCellY;
+
+ ///
+ /// A visible ARMED enemy stands within
+ /// of the headquarters, so
+ /// the units still waiting in the ring break off (r8). Always false
+ /// while the rule is off, which is what keeps that path identical.
+ ///
+ public bool HomeThreatened;
+
+ // ---- what the verdict above was reached FROM ----
+ //
+ // Not inputs to any rule: every one of these is already worked out
+ // where the gate is asked, and carrying it out of that method is
+ // what lets an observer say "1.060 of 1.200" instead of "waits".
+ // Nothing below is read by a decision, which is why adding them
+ // cannot move a single tick.
+
+ /// Which rule the gate answered with — the unit of measure of .
+ public WaveGateMode WaveMode;
+
+ /// Living combat units inside the staging ring; 0 while waves are off.
+ public int Gathered;
+
+ /// Living combat units outside the ring — out with an earlier wave.
+ public int Committed;
+
+ /// Summed combat points of the gathered units.
+ public long GatheredStrength;
+
+ /// What the ring has to hold before the wave marches, already capped by what production can still deliver.
+ public long WaveThreshold;
}
///
@@ -603,7 +687,7 @@ private struct UnitAssignment
///
private ArmyPosture ResolveArmyPosture(
FactionId faction, uint barracksRaw, int combatCount, List combatUnits,
- int hqCellX, int hqCellY)
+ int hqCellX, int hqCellY, List threatCells)
{
var posture = new ArmyPosture
{
@@ -613,6 +697,9 @@ private ArmyPosture ResolveArmyPosture(
StagingCellX = -1,
StagingCellY = -1,
WaveReady = true,
+ HomeCellX = hqCellX,
+ HomeCellY = hqCellY,
+ HomeThreatened = IsHomeThreatened(hqCellX, hqCellY, threatCells),
};
if (!posture.Engages) return posture;
@@ -644,6 +731,7 @@ private ArmyPosture ResolveArmyPosture(
int waveSize = EffectiveWaveSize();
if (waveSize <= 1) return posture;
+ posture.WaveMode = WaveGateMode.Count;
int gathered = 0;
int committed = 0;
long gatheredStrength = 0;
@@ -684,11 +772,17 @@ private ArmyPosture ResolveArmyPosture(
int producedStrength = wavePoints > 0
? CombatStrength.OfFullHealth(faction, ProducedCombatRole)
: 0;
+ posture.Gathered = gathered;
+ posture.Committed = committed;
+ posture.GatheredStrength = gatheredStrength;
+
if (wavePoints > 0 && producedStrength > 0)
{
+ posture.WaveMode = WaveGateMode.Strength;
posture.WaveReady = WaveStrengthGate.IsReady(
wavePoints, gatheredStrength, gathered, committed, producedStrength,
- _profile.TargetArmySize, canProduce: barracksRaw != 0);
+ _profile.TargetArmySize, canProduce: barracksRaw != 0,
+ out posture.WaveThreshold);
return posture;
}
@@ -713,6 +807,7 @@ private ArmyPosture ResolveArmyPosture(
if (reachable < 1) reachable = 1;
int threshold = waveSize < reachable ? waveSize : reachable;
+ posture.WaveThreshold = threshold;
posture.WaveReady = gathered >= threshold;
return posture;
}
@@ -828,6 +923,42 @@ private bool IsRetreating(in UnitState unit, in ArmyPosture posture, List
return false;
}
+ ///
+ /// Whether a visible ARMED enemy stands within
+ /// of the own headquarters — the
+ /// whole trigger of (r8).
+ ///
+ /// THREE PROPERTIES, EACH WITH A REASON. Armed, because a
+ /// harvester at the fence is not an attack, and reacting to anything
+ /// that moves is exactly what sank DefendBase (journal V002) —
+ /// filters that way already.
+ /// Visible, because anything else is a look through the fog.
+ /// Around the headquarters, not around any building: the entity
+ /// scan has that cell in hand, the observed and measured case is the one
+ /// at the headquarters, and "every building" would need a reason as well
+ /// as a wider scan. If the measurement later shows attacks on the
+ /// refinery going through the same way, that arrives as its own number.
+ ///
+ ///
+ /// A cheap ANY question, deliberately: which enemy is nearest is the
+ /// pursuer's business (), and the trigger
+ /// does not need it.
+ ///
+ ///
+ private bool IsHomeThreatened(int hqCellX, int hqCellY, List threatCells)
+ {
+ int radius = _profile.Profile.DefendHomeCells;
+ if (radius <= 0 || threatCells == null) return false;
+
+ for (int i = 0; i < threatCells.Count; i++)
+ {
+ int threatX = (int)(uint)threatCells[i];
+ int threatY = (int)(threatCells[i] >> 32);
+ if (Math.Abs(hqCellX - threatX) <= radius && Math.Abs(hqCellY - threatY) <= radius) return true;
+ }
+ return false;
+ }
+
///
/// The cells of every ARMED enemy in the team's committed view, packed
/// as (y << 32) | x. Unarmed entities are left out: a
@@ -848,10 +979,19 @@ private void CollectVisibleThreats(List cells, List raws)
if (_construction.IsActiveSite(u.Id)) continue;
if (WeaponProfiles.Get(_economy.GetSlotFaction(u.PlayerId), u.Role).AttackDamage <= 0) continue;
+ // A handle the wire format cannot express is dropped here, the
+ // same way the own scan drops it. Zero is not a spare value in
+ // this list: it wins the tie-break on the lowest raw id in
+ // NearestThreatRaw, and an assignment carrying it submits NO
+ // attack intent — the threat would silence the pursuer instead
+ // of aiming it.
+ uint raw = UnitCommandStateView.ToRawEntityId(u.Id);
+ if (raw == 0) continue;
+
long x = GridCellOf(u.Transform.PositionX);
long y = GridCellOf(u.Transform.PositionY);
cells.Add((y << 32) | x);
- raws.Add(UnitCommandStateView.ToRawEntityId(u.Id));
+ raws.Add(raw);
}
}
@@ -959,109 +1099,361 @@ private bool IsCommittedToTheWave(in UnitState unit, int hqCellX, int hqCellY)
}
///
- /// One unit's orders under the given posture. Today every combat unit
- /// gets the same two — that IS the current behaviour, and this is the
- /// one place a later rule (retreat below a health threshold, waiting
- /// at a staging cell) has to change to break the uniformity.
+ /// One unit's orders under the given posture: pick the goal, apply it.
///
- /// Aiming BELOW the squad threshold was built here and measured back
- /// out again — behaviour journal V003 carries the four variants and
- /// the reason: an explicit order cannot be handed back to the D-087
- /// auto-acquisition, because AttackTarget is released only by
- /// the target's death (UnitState.Stop() leaves it untouched).
- /// A standing unit that stops closing the distance therefore holds a
- /// stale order, and holding beats aiming only while the unit walks
- /// toward what it aims at.
+ /// THE TWO HALVES ARE SEPARATE ON PURPOSE. Picking is a chain of
+ /// conditions; applying is a table of effects. Kept together they read
+ /// as one if-cascade in which no branch has a name — and a rule without
+ /// a name can neither be switched off, nor tested on its own, nor drawn
+ /// in a panel. Split, the goal is a value: it can be recorded, forced
+ /// from outside, and told apart from the order it produced.
+ ///
+ ///
+ /// NOTHING ABOUT THE DECISION CHANGED when the names went in. The
+ /// conditions below are the same four the if-cascade tested, in the
+ /// order it tested them, and the canonical match runs tick for tick as
+ /// it did — which is the only acceptable proof for a refactor of a rule
+ /// engine, and the reason this step ships without a revision bump.
///
///
private UnitAssignment ResolveUnitAssignment(
uint entityRaw, in UnitState unit, in ArmyPosture posture, int hqCellX, int hqCellY,
- List threatCells, List threatRaws)
+ List threatCells, List threatRaws, uint tick)
{
- // A wounded unit walks home, whatever the wave is doing. This test
- // comes FIRST on purpose: retreat has to outrank "you are out with
- // the wave, keep going", or it can never pull anybody back.
+ // The two facts every module below reads, worked out once. Both are
+ // pure functions of the committed state — "already walking home" is
+ // read off the standing order, which is the AI's only memory and one
+ // that survives save/restore because it is part of the world.
bool retreats = IsRetreating(in unit, in posture, threatCells);
+ bool arrived = HasArrivedAtTheStagingCell(in unit, in posture);
- bool marches = !retreats
- && (posture.StagingCellX < 0 // no staging cell resolved
- || posture.WaveReady // the wave launches this decision
- || IsCommittedToTheWave(in unit, hqCellX, hqCellY)); // already out with an earlier wave
-
- // The one order a retreating unit still needs: its pursuer.
- // Zero does not clear the march target it is carrying — see
- // NearestThreatRaw for why leaving it stale silenced the unit for
- // the whole way home. A WAITING reinforcement keeps getting zero:
- // it holds no stale order to overwrite (it never marched), and
- // finding F001 is explicit that aiming while standing still is
- // worse than letting D-087 acquire.
- uint retreatTargetRaw = retreats
- ? NearestThreatRaw(in unit, threatCells, threatRaws)
- : 0u;
+ GoalKind goal = ResolveGoal(in unit, in posture, hqCellX, hqCellY, retreats, arrived);
- if (marches)
+ // The mask, if anybody handed one in. It replaces the pick and never
+ // the effect: a forced goal produces exactly the orders that goal
+ // always produces, so a panel cannot invent a behaviour the AI has
+ // no code for.
+ bool forced = false;
+ if (_goalOverride != null)
{
- return new UnitAssignment
+ GoalKind wanted = _goalOverride.ResolveGoal(entityRaw);
+ if (wanted != GoalKind.None)
{
- EntityRaw = entityRaw,
- AttackTargetRaw = posture.TargetRaw,
- MoveCellX = posture.MoveCellX,
- MoveCellY = posture.MoveCellY,
- };
+ forced = true;
+ goal = wanted;
+ }
}
- // Reinforcement that has ARRIVED: no order at all.
+ // The pursuer, and only for the goals that carry one.
//
- // This is not an optimisation, it is the difference between a wave
- // and a stutter. Arrival clears TargetGridPos through Stop(), so
- // the re-issue suppression in SubmitAssignments stops matching and
- // the same move order goes out again every single cadence. Measured
- // before this branch existed: 40 actions per minute against 23 for
- // the shipped AI, for units that were standing still. Intent churn
- // without a change of behaviour is exactly what sank DefendBase
- // (journal V002), and the fix is to say nothing when there is
- // nothing to say.
- // "Arrived" means standing there, not merely being there. A unit
- // that is inside the tolerance but still WALKING is walking
- // somewhere else — saying nothing to it lets it carry on out of
- // the ring, which is the opposite of what both rules want. A test
- // found this: a wounded unit twelve cells from its HQ kept its
- // march order and walked on toward the enemy, because it happened
- // to pass within four cells of the staging cell.
- if (!unit.IsMoving && IsAtTheStagingCell(in unit, in posture))
+ // Zero does not CLEAR a march target a unit is carrying — see
+ // NearestThreatRaw for why leaving it stale silenced a retreating
+ // unit for the whole way home. So Retreat overwrites the stale order
+ // with the thing chasing it, and Hold does the same for a unit that
+ // ran home and is still under the rule. A fresh reinforcement gets
+ // zero and should: it holds no stale order to overwrite, and aiming
+ // while standing still is worse than letting D-087 acquire (F001).
+ uint pursuerRaw = goal == GoalKind.Retreat || goal == GoalKind.DefendHome
+ || (goal == GoalKind.Hold && retreats)
+ ? NearestThreatRaw(in unit, threatCells, threatRaws)
+ : 0u;
+
+ // Whether the defender is already standing at the base — the second
+ // caller-computed fact the table reads, for the same reason the
+ // pursuer is one: a defender that has arrived must fall silent, or
+ // the headquarters cell goes out again every cadence. See
+ // HasArrivedAtHome.
+ bool atHome = HasArrivedAtHome(in unit, in posture);
+
+ UnitAssignment assignment = ApplyGoal(goal, entityRaw, in posture, pursuerRaw, atHome);
+
+ if (_goalObserver != null)
{
- return new UnitAssignment
- {
- EntityRaw = entityRaw,
- AttackTargetRaw = retreatTargetRaw,
- MoveCellX = -1,
- MoveCellY = -1,
- };
+ ReportUnitGoal(tick, in unit, in posture, in assignment,
+ goal, forced, hqCellX, hqCellY, threatCells);
}
+ return assignment;
+ }
- // Reinforcement still on its way: walk to the staging cell.
- //
- // NO EXPLICIT ATTACK TARGET while waiting, and that is a
- // consequence of finding F001, not an oversight. An AttackTarget
- // is released only by the target's death — Stop() leaves it
- // standing — so a unit that is NOT closing the distance holds a
- // stale order and stops firing, while the D-087 auto-acquisition
- // would have shot at whatever came into range. Aiming is right
- // while a unit walks toward what it aims at (journal V003), and a
- // waiting unit does not.
- //
- // A RETREATING unit is the exception, and for the same reason
- // rather than against it: it is not a fresh reinforcement, it is
- // already carrying a march target it can no longer reach. Silence
- // does not release that order, so silence is what kept it from
- // firing. It gets its pursuer instead (retreatTargetRaw).
- return new UnitAssignment
+ ///
+ /// WHICH GOAL THIS UNIT IS UNDER — the five conditions, in priority
+ /// order, exactly as lists them.
+ ///
+ /// THREE CLAUSES ARE NOT OBVIOUS AND ALL THREE ARE LOAD-BEARING.
+ ///
+ ///
+ /// - Retreat steps aside for a unit that has arrived.
+ /// Getting home ENDS the retreat: from there it is an ordinary waiting
+ /// unit that leaves with the next wave, wounded or not. MS-1 units never
+ /// heal (Repair validates its target as a completed BUILDING), so
+ /// a rule that kept them under Retreat until they recovered would
+ /// pile the wounded up at home, occupy the army cap with them, and never
+ /// fill another wave. This clause is that rule, written where it can be
+ /// read.
+ /// - Attack asks not to be retreating. A wounded unit
+ /// that is already outside the staging ring satisfies every other half
+ /// of the march test, so without this the pull-back could never reach
+ /// the one unit it exists for. Retreat has to outrank "you are out with
+ /// the wave, keep going".
+ /// - DefendHome sits between the two, and both sides of
+ /// that are load-bearing. It gives way to Retreat because a
+ /// unit too wounded to fight is no defender — and it outranks
+ /// Attack because the units it is for would otherwise gather for
+ /// a wave that marches away from their burning base. It asks only about
+ /// units still INSIDE the ring: a wave that is already out keeps going,
+ /// which is the r3 rule that made a wave a wave and the V002 failure
+ /// mode if it were dropped.
+ /// - It does NOT ask whether the unit is wounded. It cannot,
+ /// and the first version that did was wrong: Retreat has already
+ /// returned above for every unit still running, so the only wounded unit
+ /// that reaches this line is one that HAS ARRIVED — and arriving ends
+ /// the retreat by the rule two clauses up. Asking again handed exactly
+ /// those units to Hold, so under siege the ones who were already
+ /// home stood twelve cells out at the staging cell, aiming at a pursuer
+ /// they could not reach, while the base they had run back to burned.
+ /// A unit that is home enough to leave with the next wave is home enough
+ /// to defend.
+ ///
+ ///
+ private GoalKind ResolveGoal(
+ in UnitState unit, in ArmyPosture posture, int hqCellX, int hqCellY,
+ bool retreats, bool arrived)
+ {
+ if (retreats && !arrived) return GoalKind.Retreat;
+
+ // Asked once and read twice — the defence needs to know that the
+ // unit is still gathering, and the march test needs the same fact.
+ bool committed = IsCommittedToTheWave(in unit, hqCellX, hqCellY);
+
+ if (posture.HomeThreatened && !committed) return GoalKind.DefendHome;
+ if (!retreats && IsFitToMarch(in posture, committed)) return GoalKind.Attack;
+ if (arrived) return GoalKind.Hold;
+ return GoalKind.Advance;
+ }
+
+ ///
+ /// Whether the wave carries this unit forward: no staging cell was
+ /// resolved at all, or the wave launches this decision, or the unit is
+ /// already out with an earlier one and is not called back.
+ ///
+ private static bool IsFitToMarch(in ArmyPosture posture, bool committed)
+ {
+ return posture.StagingCellX < 0
+ || posture.WaveReady
+ || committed;
+ }
+
+ ///
+ /// True when the unit is STANDING at the staging cell — not merely
+ /// standing near it while walking somewhere else.
+ ///
+ /// The distinction was found by a test, not reasoned out: a wounded unit
+ /// twelve cells from its HQ kept its march order and walked on toward
+ /// the enemy, because its route happened to pass within four cells of
+ /// the staging point. "Is there" and "has arrived" are different
+ /// questions, and only the second one may buy silence.
+ ///
+ ///
+ private bool HasArrivedAtTheStagingCell(in UnitState unit, in ArmyPosture posture)
+ {
+ return posture.StagingCellX >= 0 && !unit.IsMoving && IsAtTheStagingCell(in unit, in posture);
+ }
+
+ ///
+ /// True when a defender is HOME and standing — within
+ /// of the headquarters and
+ /// not walking. falls silent for it,
+ /// the way is silent at the staging cell.
+ ///
+ /// WITHOUT THIS THE STATIC DESTINATION DOES NOT HELP, and the argument
+ /// the rule was built on is only half true. The re-issue suppression in
+ /// compares the STANDING ORDER
+ /// (UnitState.TargetGridPos) — and MovementSystem calls
+ /// UnitState.Stop() on arrival, which invalidates exactly that
+ /// field. So a defender that has arrived has nothing left to compare
+ /// against, the suppression stops recognising the repeat, and the
+ /// headquarters cell goes out again every single cadence: measured, one
+ /// move intent per cadence for as long as the siege lasts, with eight
+ /// standing units flipped back into IsMoving each time. Static
+ /// destinations survive the suppression only WHILE THEY ARE BEING
+ /// WALKED TO; standing still needs the same silence Hold has.
+ ///
+ ///
+ /// The tolerance is the staging one and deliberately not a new profile
+ /// value: it is the same phenomenon (a group arriving spreads over
+ /// several cells, and the headquarters footprint is impassable, so ring
+ /// 0 is never claimed), and a new number would move
+ /// AiProfile.ProfileHash for a correction that changes no rule.
+ /// Four cells around the headquarters cannot collide with the staging
+ /// cell either — that one sits twelve cells out.
+ ///
+ ///
+ private bool HasArrivedAtHome(in UnitState unit, in ArmyPosture posture)
+ {
+ if (unit.IsMoving) return false;
+ int distance = Chebyshev(
+ GridCellOf(unit.Transform.PositionX), GridCellOf(unit.Transform.PositionY),
+ posture.HomeCellX, posture.HomeCellY);
+ return distance <= _profile.Profile.StagingToleranceCells;
+ }
+
+ ///
+ /// THE EFFECT OF A GOAL — one table, five rows, no conditions.
+ ///
+ /// Every row is a pure function of the goal, the posture and the two
+ /// facts the caller worked out (the pursuer, and whether a defender is
+ /// already home), which is what makes a forced goal safe: a panel that
+ /// names Retreat for a healthy unit gets the orders
+ /// Retreat always produces, not a state the AI has no code for.
+ ///
+ ///
+ /// The two "no order" values are a real part of the vocabulary and not
+ /// a missing case: attack target 0 submits no attack intent and leaves
+ /// the D-087 auto-acquisition its pick, move cell -1 leaves the unit
+ /// walking wherever it already was. Hold is built out of both,
+ /// and its silence is the whole reason a wave looks like a wave instead
+ /// of a stutter.
+ ///
+ ///
+ private static UnitAssignment ApplyGoal(
+ GoalKind goal, uint entityRaw, in ArmyPosture posture, uint pursuerRaw, bool atHome)
+ {
+ switch (goal)
{
- EntityRaw = entityRaw,
- AttackTargetRaw = retreatTargetRaw,
- MoveCellX = posture.StagingCellX,
- MoveCellY = posture.StagingCellY,
- };
+ case GoalKind.Retreat:
+ return new UnitAssignment
+ {
+ EntityRaw = entityRaw,
+ AttackTargetRaw = pursuerRaw,
+ MoveCellX = posture.StagingCellX,
+ MoveCellY = posture.StagingCellY,
+ };
+
+ case GoalKind.Attack:
+ return new UnitAssignment
+ {
+ EntityRaw = entityRaw,
+ AttackTargetRaw = posture.TargetRaw,
+ MoveCellX = posture.MoveCellX,
+ MoveCellY = posture.MoveCellY,
+ };
+
+ case GoalKind.Hold:
+ return new UnitAssignment
+ {
+ EntityRaw = entityRaw,
+ AttackTargetRaw = pursuerRaw,
+ MoveCellX = -1,
+ MoveCellY = -1,
+ };
+
+ case GoalKind.DefendHome:
+ // The same shape Retreat has, aimed at the other static
+ // cell: walk to the headquarters, shoot the nearest armed
+ // enemy on the way. Static is the operative word — the
+ // headquarters does not move all match, so while a defender
+ // is WALKING the re-issue suppression in SubmitAssignments
+ // swallows every repeat. DefendBase aimed at the ENEMY,
+ // which moves, and paid a fresh order per unit per cadence
+ // for it (journal V002).
+ //
+ // ONCE HOME IT FALLS SILENT, and that is not a nicety. The
+ // suppression compares the standing order, MovementSystem
+ // clears the standing order on arrival, and a defender that
+ // has arrived would therefore be sent home AGAIN every
+ // cadence — measured before this row existed: one move
+ // intent per cadence for the whole siege and eight standing
+ // units flipped back into IsMoving each time, which is the
+ // V002 shape at a smaller size. Hold's silence, for the
+ // other static cell. See HasArrivedAtHome.
+ return new UnitAssignment
+ {
+ EntityRaw = entityRaw,
+ AttackTargetRaw = pursuerRaw,
+ MoveCellX = atHome ? -1 : posture.HomeCellX,
+ MoveCellY = atHome ? -1 : posture.HomeCellY,
+ };
+
+ default:
+ // Advance — and GoalKind.None with it. A mask that names
+ // None never reaches here (it means "leave it to the AI"),
+ // so the fall-through is the walk to the staging cell.
+ return new UnitAssignment
+ {
+ EntityRaw = entityRaw,
+ AttackTargetRaw = 0u,
+ MoveCellX = posture.StagingCellX,
+ MoveCellY = posture.StagingCellY,
+ };
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Reporting — reached only when somebody attached an observer, which
+ // the shipped game never does. Every measurement below is taken HERE
+ // and nowhere else on the decision path, so none of it can cost the
+ // delivered build anything.
+ // ------------------------------------------------------------------
+
+ private void ReportArmyGoal(uint tick, in ArmyPosture posture)
+ {
+ _goalObserver.OnArmyGoal(_aiPlayerId, tick, new AiArmyGoal(
+ posture.Engages, posture.TargetRaw, posture.MoveCellX, posture.MoveCellY,
+ posture.StagingCellX, posture.StagingCellY, posture.WaveReady, posture.WaveMode,
+ posture.Gathered, posture.Committed, posture.GatheredStrength, posture.WaveThreshold,
+ posture.HomeThreatened));
+ }
+
+ ///
+ /// The unit's goal together with the QUANTITIES its conditions weighed —
+ /// health against the retreat percentage, distances against the staging
+ /// tolerance and the ring. A reader with these four numbers and the
+ /// profile can work out how far the unit is from its next goal without
+ /// anybody re-implementing the rules beside the recording.
+ ///
+ private void ReportUnitGoal(
+ uint tick, in UnitState unit, in ArmyPosture posture, in UnitAssignment assignment,
+ GoalKind goal, bool forced, int hqCellX, int hqCellY, List threatCells)
+ {
+ int cellX = GridCellOf(unit.Transform.PositionX);
+ int cellY = GridCellOf(unit.Transform.PositionY);
+
+ _goalObserver.OnUnitGoal(_aiPlayerId, tick, new AiUnitGoal(
+ assignment.EntityRaw,
+ goal,
+ forced,
+ assignment.AttackTargetRaw,
+ assignment.MoveCellX,
+ assignment.MoveCellY,
+ unit.MaxHealth > 0 ? (int)((long)unit.CurrentHealth * 100 / unit.MaxHealth) : 0,
+ NearestThreatDistance(cellX, cellY, threatCells),
+ posture.StagingCellX < 0
+ ? -1
+ : Chebyshev(cellX, cellY, posture.StagingCellX, posture.StagingCellY),
+ Chebyshev(cellX, cellY, hqCellX, hqCellY)));
+ }
+
+ ///
+ /// Cells to the nearest visible armed enemy, or -1 when none is visible
+ /// or the retreat rule never collected any. Report-only — the rule
+ /// itself asks a cheaper question (is ANY of them within the radius) and
+ /// keeps asking it.
+ ///
+ private static int NearestThreatDistance(int cellX, int cellY, List threatCells)
+ {
+ if (threatCells == null) return -1;
+ int nearest = -1;
+ for (int i = 0; i < threatCells.Count; i++)
+ {
+ int distance = Chebyshev(cellX, cellY, (int)(uint)threatCells[i], (int)(threatCells[i] >> 32));
+ if (nearest < 0 || distance < nearest) nearest = distance;
+ }
+ return nearest;
+ }
+
+ private static int Chebyshev(int ax, int ay, int bx, int by)
+ {
+ return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
}
///
diff --git a/Assets/_Project/Scripts/AI/WaveStrengthGate.cs b/Assets/_Project/Scripts/AI/WaveStrengthGate.cs
index 6fcc1a9..95d9db8 100644
--- a/Assets/_Project/Scripts/AI/WaveStrengthGate.cs
+++ b/Assets/_Project/Scripts/AI/WaveStrengthGate.cs
@@ -101,8 +101,41 @@ public static bool IsReady(
int armyCap,
bool canProduce)
{
- return gatheredStrength >= Threshold(
+ return IsReady(
+ wavePoints, gatheredStrength, gathered, committed, producedStrength, armyCap, canProduce,
+ out _);
+ }
+
+ ///
+ /// The same verdict, and the it was reached
+ /// against.
+ ///
+ /// THE NUMBER IS WORTH HANDING OUT because it is the one thing "the wave
+ /// waits" does not say: how far off it is. The gap between what the ring
+ /// holds and this value is what the admin panel shows as "another 140
+ /// points before it marches", and it is exact rather than estimated —
+ /// every clause here is integer arithmetic.
+ ///
+ ///
+ /// It is an overload rather than a second computation at the call site
+ /// so the comparison operator still exists exactly once. Deleting the
+ /// clamp used to leave the whole suite green (see the class remarks);
+ /// two copies of the comparison would be the same trap one level up.
+ ///
+ ///
+ public static bool IsReady(
+ int wavePoints,
+ long gatheredStrength,
+ int gathered,
+ int committed,
+ int producedStrength,
+ int armyCap,
+ bool canProduce,
+ out long threshold)
+ {
+ threshold = Threshold(
wavePoints, gatheredStrength, gathered, committed, producedStrength, armyCap, canProduce);
+ return gatheredStrength >= threshold;
}
}
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cdd6d71..7c2ab4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -65,6 +65,103 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
sie belegt keine Verbesserung.
### Geändert
+- **Die Skirmish-KI benennt, was eine Einheit vorhat — und verteidigt damit
+ ihre Basis (`GoalKind`, `r7` → `r8`)** — zwei Schritte, die zusammengehören
+ und deshalb zusammen kommen: erst bekommt die Entscheidung eine **Form**,
+ dann bekommt die Form ihre erste eigene **Regel**. Die Reihenfolge ist der
+ Punkt — wer zuerst eine Regel und dabei die Form ändert, kann hinterher
+ nicht sagen, welche der beiden gewirkt hat.
+
+ **Die Form, verhaltensneutral.** Der Armeeschritt entschied in einer
+ if-Kette, in der keine Verzweigung einen Namen hatte. Er wählt jetzt je
+ Einheit und Kadenz **ein** Goal aus einer festen Prioritätsliste und wendet
+ dessen Wirkung aus einer Tabelle an. Dieser Schritt für sich ist **keine**
+ Verhaltensänderung, und der Nachweis ist kein Test, sondern eine Zahl: die
+ kanonische Partie entscheidet auf demselben Tick mit demselben Endzustand,
+ die Artefakte eines Laborlaufs sind byte-identisch bis auf die gemessene
+ Laufzeit. Ein Goal ist **kein Zustand** — es wird je Kadenz neu abgeleitet
+ und nirgends gespeichert, die KI bleibt eine reine Funktion des committeten
+ Zustands, es entsteht kein Sidecar-Block. Dazu zwei optionale Nähte, die der
+ ausgelieferte Pfad nie füllt und die deshalb nichts kosten:
+ `IAiGoalObserver` lässt mitlesen, welches Goal eine Einheit bekommen hat und
+ mit welchen Zahlen die Bedingung entschieden hat, `IAiGoalOverride` erlaubt
+ es, einem Goal von aussen vorzugreifen — als **Eingabe** der Entscheidung,
+ nicht als gespeicherter Zustand. `MatchRunner` übergibt keine von beiden.
+
+ **Die Regel: `DefendHome`.** Eine Einheit, die am Sammelpunkt angekommen
+ ist, bekommt absichtlich **keinen** Befehl und hängt damit allein an der
+ D-087-Auto-Acquisition. Die reicht so weit wie die Waffe: sechs Zellen bei
+ der Legions-Infanterie, sieben beim Allianz-Schützen. Der Sammelpunkt liegt
+ **zwölf** Zellen vom eigenen HQ. Ein Angreifer an der Basis war damit
+ ausserhalb jeder Reichweite — die Wartenden haben ihn nicht ignoriert,
+ **sie haben ihn nicht gesehen**. Gemessen in der kanonischen Partie: das
+ Legions-HQ nimmt über **766 Ticks 327 Treffer**, während die eigenen
+ Einheiten im Median **13 Zellen** entfernt unter `Hold` stehen und **keine
+ einzige** angreift. Der Defekt ist so alt wie der Sammelpunkt (`r3`).
+ Neues Goal `DefendHome` mit dem Profilfeld `defendHomeCells` (ausgeliefert
+ **10**, **0 = aus**): wer noch **im Sammelring** steht, marschiert zum
+ eigenen HQ und zielt auf den nächsten sichtbaren bewaffneten Gegner. Wer
+ **draussen** ist, marschiert weiter — die `r3`-Regel „Einheiten draussen
+ werden nie zurückgerufen" bleibt, und die Welle wird **unterbrochen, nicht
+ freigegeben**. Das Ziel ist die **statische** HQ-Zelle und ausdrücklich
+ nicht der Gegner: genau daran ist `DefendBase` gescheitert (+23 % Intents,
+ schlechteres Spiel), weil ein bewegliches Ziel jede Kadenz einen neuen
+ Befehl für jede Einheit erzeugt.
+
+ **Zwei Korrekturen an `DefendHome`, bevor es ausgeliefert wird.** Beide
+ gefunden beim Prüfen der Begründung, nicht des Codes; beide bewegen
+ `ProfileHash` nicht, weil keine eine Zahl hinzufügt.
+
+ 1. **Die statische Zielzelle allein trägt das Argument nicht.** Die
+ Re-Issue-Unterdrückung vergleicht den **stehenden** Befehl, und
+ `MovementSystem` **löscht** genau den bei der Ankunft
+ (`UnitState.Stop()`). Ein Verteidiger, der angekommen ist, hatte damit
+ nichts mehr zu vergleichen — die HQ-Zelle ging **jede Kadenz erneut
+ raus**, gemessen ein Move-Intent pro Kadenz für die Dauer der
+ Belagerung, mit acht stehenden Einheiten, die jedes Mal wieder in
+ `IsMoving` kippten. Das ist die `V002`-Form eine Nummer kleiner.
+ `DefendHome` schweigt jetzt selbst, sobald die Einheit daheim steht —
+ dieselbe Stille, die `Hold` am Sammelpunkt hat.
+ 2. **Die Regel fragte, ob die Einheit gerade zurückzieht.** Diese Klausel
+ konnte nur Verwundete treffen, die **schon angekommen** waren —
+ Laufende nimmt `Retreat` eine Zeile früher. Ankommen **beendet** den
+ Rückzug aber nach der eigenen Regel der KI. Die Klausel hat also nichts
+ bewirkt ausser genau diese Einheiten an `Hold` zu geben: zwölf Zellen
+ draussen stehend, auf einen Verfolger zielend, den sie nicht erreichen,
+ während die Basis brennt, zu der sie zurückgelaufen waren.
+
+ **Was die Regel bringt und was sie kostet.** Der Verhaltensbezeichner bewegt
+ beide Hälften: die Revision, weil Entscheidungen sich ändern, und
+ `ProfileHash`, weil die Regel mit ihrer Aus-Stellung ausgeliefert wird.
+ `defendHomeCells: 0` auf beiden Sitzen ergibt **bitgenau** die Partie von
+ `r7` (Tick 3.213, `0xE002DD893916967B`) — der Aus-Pfad ist von den beiden
+ Korrekturen nicht berührt.
+
+ **Die Wirkungszahlen sind noch die von vor den Korrekturen und gelten
+ deshalb nicht mehr.** Gemessen wurde am unkorrigierten `DefendHome`:
+ Wehrlosigkeit im Beschussfenster **96 % → 60 %**, Partiedauer der Legion
+ **3.213 → 6.490** Ticks, eigene Verluste **18 → 60**, und sie gewinnt die
+ Partie trotzdem nicht. Beide Korrekturen ändern ausgegebene Befehle und
+ damit den Verlauf; die Zahlen werden **vor dem Merge neu gemessen** und
+ hier ersetzt. Sie stehen hier als das, was sie sind — die Grössenordnung
+ eines Vorläufers, kein Nachweis für den ausgelieferten Stand.
+
+ **Bekannte Lücke, nicht behoben:** unterhalb der Squad-Schwelle
+ (`attackSquadThreshold`, ausgeliefert 6) läuft der Armeeschritt gar nicht,
+ also verteidigt **niemand** — genau in dem Fenster, in dem die Basis am
+ schwächsten ist. Der Armeebericht meldet dabei `HomeThreatened: true`,
+ während nichts geschieht. Das zu ändern heisst, Einheiten unterhalb der
+ Schwelle überhaupt zu beurteilen, und das ist eine Verhaltensausweitung mit
+ eigener Messpflicht — sie gehört in einen eigenen PR, nicht in diesen.
+
+ **Am Player angesehen:** die Regel ist in der Laboraufnahme
+ `Nova.AiLab-goal-base-defense-r8-20260810` in Bewegung zu sehen — die
+ Wartenden lösen sich vom Sammelpunkt und marschieren zum eigenen HQ. Das ist
+ die Gegenaufnahme zu der, in der genau diese Einheiten weitersammeln, während
+ ihr Hauptquartier fällt. **Die Aufnahme zeigt den Stand vor den beiden
+ Korrekturen oben.**
+ **Im laufenden Spiel gesehen: nein.** Eine Aufzeichnung des Labors ist keine
+ gespielte Partie; alles oben ist gemessen, nicht gespielt.
- **16.7/C1: Fünf endliche Aetheriumfelder schaffen Knappheit (D-102)** — die
zwei praktisch endlosen Startfelder werden durch zwei symmetrische
Startfelder und zwei Expansionen mit je 9.000 AE sowie ein umkämpftes
diff --git a/tools/Nova.SimRunner.Tests/AiProfileTests.cs b/tools/Nova.SimRunner.Tests/AiProfileTests.cs
index 19141c1..d7327c4 100644
--- a/tools/Nova.SimRunner.Tests/AiProfileTests.cs
+++ b/tools/Nova.SimRunner.Tests/AiProfileTests.cs
@@ -258,7 +258,8 @@ public void AProfileCanBeTunedThroughTheDataType()
targetFinishWeight: 2, targetDistanceWeight: 5,
waveSize: 5, stagingDistanceCells: 20, stagingToleranceCells: 3,
retreatHealthPercent: 30, retreatDangerCells: 6,
- waveStrengthPoints: 900);
+ waveStrengthPoints: 900,
+ defendHomeCells: 14);
var bound = new AiFactionProfile("Legion", tuned);
diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs
index 2b21f81..309cd48 100644
--- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs
+++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs
@@ -83,12 +83,26 @@ internal sealed class AiHost
/// clock to the tick about to execute (its intents then target
/// T+1), step, and advance the human session.
///
+ ///
+ /// How many intents the AI has put on the wire since the host was
+ /// built — the first number any behaviour change is judged by.
+ ///
+ /// DefendBase was measured back out again over 23 % more
+ /// intents and nothing else (journal V002), and no test could see
+ /// that at the time because nothing counted. This does. It is a
+ /// TEST-SIDE tally over the sealed batches, not a field on the AI:
+ /// counting inside the simulation would be state the AI could read.
+ ///
+ ///
+ public int IntentsSubmitted;
+
public void Step()
{
uint nextTick = Kernel.CurrentTick.Value + 1;
CommandBatch batch = Ingress.SealTickBatch(nextTick);
if (batch.Count > 0)
{
+ IntentsSubmitted += batch.Count;
Assert.That(Kernel.SubmitBatch(batch), Is.True,
$"kernel refused the sealed batch of tick {nextTick}");
}
@@ -151,10 +165,18 @@ private static AiProfile WavesOff()
retreatDangerCells: shipped.RetreatDangerCells,
// Waves off means waves off: the strength gate is the same
// rule and switches off with them.
- waveStrengthPoints: 0);
+ waveStrengthPoints: 0,
+ // And the defence with them. This profile exists to reproduce
+ // the behaviour from BEFORE the wave rules, and a defence that
+ // pulls gatherers home is a rule of the same generation.
+ defendHomeCells: 0);
}
- private static AiHost BuildAiHost(ulong seed, AiProfile? profile = null)
+ private static AiHost BuildAiHost(
+ ulong seed,
+ AiProfile? profile = null,
+ IAiGoalObserver goalObserver = null,
+ IAiGoalOverride goalOverride = null)
{
// Mirror of MatchRunner.InitializeMatch(seed, ..., enableSkirmishAi: true).
var kernel = new SimulationKernel(new SimRandom(seed));
@@ -182,7 +204,8 @@ private static AiHost BuildAiHost(ulong seed, AiProfile? profile = null)
? new AiFactionProfile("Legion", profile.Value)
: new AiFactionProfile("Legion",
targetPowerMargin: 0, targetArmySize: 12, attackSquadThreshold: 6, targetHarvesterCount: 2),
- aiIngress, entities, economy, construction, production, fogOfWar, victory);
+ aiIngress, entities, economy, construction, production, fogOfWar, victory,
+ goalObserver, goalOverride);
kernel.RegisterSystem(economy);
kernel.RegisterSystem(construction);
@@ -254,9 +277,19 @@ private static void ApplyOpeningPosition(AiHost host)
}
}
- internal static AiHost BuildMatch(ulong seed, AiProfile? profile = null)
+ ///
+ /// The canonical match. The last two arguments are the admin panel's
+ /// two seams and the shipped game passes neither — a run that hands in
+ /// an observer has to reach the SAME end state as one that does not,
+ /// which is what SkirmishGoalTests asserts.
+ ///
+ internal static AiHost BuildMatch(
+ ulong seed,
+ AiProfile? profile = null,
+ IAiGoalObserver goalObserver = null,
+ IAiGoalOverride goalOverride = null)
{
- AiHost host = BuildAiHost(seed, profile);
+ AiHost host = BuildAiHost(seed, profile, goalObserver, goalOverride);
ApplyOpeningPosition(host);
return host;
}
@@ -513,7 +546,15 @@ public void AiBehaviorId_TracksWhichAiThisIs()
// outcome unmoved is exactly the "declared change, no effect yet"
// case — under the old coupled pin it was indistinguishable from a
// simulation change.
- Assert.That(AiBehaviorId.Value, Is.EqualTo("r7.E34435F9"),
+ //
+ // r8 moves BOTH halves at once, and that combination is itself the
+ // statement: the revision because decisions change (DefendHome
+ // breaks the gatherers off when the base is under attack), and the
+ // profile hash because the rule ships with the off switch that
+ // 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("r8.1E6E7AE3"),
"the AI identifier changed — bump the revision and write the journal entry");
}
@@ -814,7 +855,11 @@ public void SkirmishAi_LaunchesTheNextWave_WhenTheArmyCapCannotRefillIt()
retreatDangerCells: shipped.RetreatDangerCells,
// The COUNT path on purpose — this test pins the r5 rule, and
// the strength path has its own test.
- waveStrengthPoints: 0);
+ waveStrengthPoints: 0,
+ // Defence off for the same reason: this test is about a wave
+ // that must launch, and a rule that can hold units at home is
+ // a second explanation for a wave that does not.
+ defendHomeCells: 0);
AiHost host = BuildMatch(Seed, probe);
int ring = probe.StagingDistanceCells + probe.StagingToleranceCells;
@@ -934,7 +979,11 @@ private static AiProfile StrengthGateProbe(int waveStrengthPoints)
stagingToleranceCells: shipped.StagingToleranceCells,
retreatHealthPercent: 0,
retreatDangerCells: shipped.RetreatDangerCells,
- waveStrengthPoints: waveStrengthPoints);
+ waveStrengthPoints: waveStrengthPoints,
+ // One variable per probe. The gate is what this measures; a
+ // defence that can pull gatherers home would be a second
+ // reason for a wave to launch later than it did.
+ defendHomeCells: 0);
}
///
diff --git a/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs
new file mode 100644
index 0000000..53ef408
--- /dev/null
+++ b/tools/Nova.SimRunner.Tests/SkirmishGoalTests.cs
@@ -0,0 +1,976 @@
+using System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using Nova.AI;
+using Nova.AI.Data;
+using Nova.Core;
+using Nova.Simulation.Definitions;
+using Nova.Simulation.State;
+
+namespace Nova.SimRunner.Tests
+{
+ ///
+ /// The named goals of the skirmish AI, and the two seams the lab's admin
+ /// panel hangs on them: an observer that watches the decision and a mask
+ /// that forces one.
+ ///
+ /// WHAT THIS SUITE IS ACTUALLY GUARDING. Naming the branches of the army
+ /// step was supposed to change nothing at all — the proof of that is the
+ /// pinned end state in and a lab run
+ /// whose artifacts stayed byte-identical, not an assertion here. What a test
+ /// CAN hold, and what the pin cannot, is the property that makes the names
+ /// worth having: the goal that is reported is the goal that produced the
+ /// orders. A panel drawing a goal the unit is not under would be worse than
+ /// a panel drawing nothing, because it looks like an answer.
+ ///
+ ///
+ /// The observer and the mask are both null in the shipped game
+ /// (MatchRunner passes neither), so two of the tests below simply
+ /// check that handing one in does not move the match. That is the whole
+ /// licence for compiling them into the delivered build.
+ ///
+ ///
+ [TestFixture]
+ public sealed class SkirmishGoalTests
+ {
+ private const byte AiSlot = 1;
+
+ // ----------------------------------------------------------------
+ // Test doubles
+ // ----------------------------------------------------------------
+
+ /// One reported unit decision, with the tick it was taken at.
+ private readonly struct UnitEntry
+ {
+ public readonly uint Tick;
+ public readonly AiUnitGoal Goal;
+
+ public UnitEntry(uint tick, in AiUnitGoal goal)
+ {
+ Tick = tick;
+ Goal = goal;
+ }
+ }
+
+ /// One reported army decision, with the tick it was taken at.
+ private readonly struct ArmyEntry
+ {
+ public readonly uint Tick;
+ public readonly AiArmyGoal Goal;
+
+ public ArmyEntry(uint tick, in AiArmyGoal goal)
+ {
+ Tick = tick;
+ Goal = goal;
+ }
+ }
+
+ ///
+ /// Keeps every decision it is told about. A pure sink: it reads nothing
+ /// back into the match, which is the property the byte-identity test
+ /// below actually verifies.
+ ///
+ private sealed class RecordingObserver : IAiGoalObserver
+ {
+ public readonly List Army = new List();
+ public readonly List Units = new List();
+
+ public void OnArmyGoal(byte slot, uint tick, in AiArmyGoal army)
+ {
+ Army.Add(new ArmyEntry(tick, in army));
+ }
+
+ public void OnUnitGoal(byte slot, uint tick, in AiUnitGoal goal)
+ {
+ Units.Add(new UnitEntry(tick, in goal));
+ }
+
+ /// The army decision of the tick a unit decision belongs to.
+ public AiArmyGoal ArmyAt(uint tick)
+ {
+ for (int i = 0; i < Army.Count; i++)
+ {
+ if (Army[i].Tick == tick) return Army[i].Goal;
+ }
+ throw new InvalidOperationException(
+ $"no army decision was reported for tick {tick}, but a unit decision was");
+ }
+ }
+
+ /// The same goal for every unit — or none at all, which is the off setting.
+ private sealed class FixedMask : IAiGoalOverride
+ {
+ private readonly GoalKind _goal;
+
+ public FixedMask(GoalKind goal)
+ {
+ _goal = goal;
+ }
+
+ public GoalKind ResolveGoal(uint entityRaw) => _goal;
+ }
+
+ // ----------------------------------------------------------------
+ // The names describe the decision
+ // ----------------------------------------------------------------
+
+ ///
+ /// Every judged unit is under exactly one of the five goals, and the
+ /// three the canonical match has to contain all show up.
+ ///
+ /// Retreat and DefendHome are NOT among them and cannot
+ /// be: the opponent of this match is passive and owns no armed unit, so
+ /// no threat is ever visible — nothing turns back and nothing comes
+ /// home. Both have their own tests below with a threat spawned in — the
+ /// same reason
+ /// SkirmishAi_PullsWoundedUnitsBackTowardTheirOwnBase exists
+ /// beside the pinned end-to-end run.
+ ///
+ ///
+ [Test]
+ public void EveryJudgedUnitIsUnderExactlyOneNamedGoal()
+ {
+ var observer = new RecordingObserver();
+ SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, goalObserver: observer);
+ host.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks);
+
+ Assert.That(observer.Units, Is.Not.Empty, "no unit decision was reported at all");
+
+ bool attacked = false, held = false, advanced = false;
+ for (int i = 0; i < observer.Units.Count; i++)
+ {
+ GoalKind goal = observer.Units[i].Goal.Goal;
+ Assert.That(goal, Is.Not.EqualTo(GoalKind.None),
+ "a unit was judged and came out unnamed — the catalogue does not cover the decision");
+ if (goal == GoalKind.Attack) attacked = true;
+ else if (goal == GoalKind.Hold) held = true;
+ else if (goal == GoalKind.Advance) advanced = true;
+ }
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(attacked, Is.True, "no unit ever marched on the target");
+ Assert.That(held, Is.True, "no reinforcement ever waited at the staging cell");
+ Assert.That(advanced, Is.True, "no reinforcement ever walked to the staging cell");
+ });
+ }
+
+ ///
+ /// THE ORDERS ARE THE ONES THE REPORTED GOAL PRODUCES — the single
+ /// property that makes a goal panel trustworthy.
+ ///
+ /// It is checked against the ARMY decision of the same tick rather than
+ /// against a copy of the effect table, so a change that moved an effect
+ /// without moving the name would fail here. That is the failure mode
+ /// worth catching: names and effects drifting apart is invisible in
+ /// every other artifact, because the match plays on regardless.
+ ///
+ ///
+ [Test]
+ public void TheOrdersThatWentOutAreTheOnesTheReportedGoalProduces()
+ {
+ var observer = new RecordingObserver();
+ SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, goalObserver: observer);
+ host.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks);
+
+ for (int i = 0; i < observer.Units.Count; i++)
+ {
+ UnitEntry entry = observer.Units[i];
+ AiUnitGoal unit = entry.Goal;
+ AiArmyGoal army = observer.ArmyAt(entry.Tick);
+ string where = $"unit {unit.EntityRaw} at tick {entry.Tick} under {unit.Goal}";
+
+ switch (unit.Goal)
+ {
+ case GoalKind.Attack:
+ Assert.That(unit.MoveCellX, Is.EqualTo(army.MoveCellX), where);
+ Assert.That(unit.MoveCellY, Is.EqualTo(army.MoveCellY), where);
+ Assert.That(unit.AttackTargetRaw, Is.EqualTo(army.TargetRaw), where);
+ break;
+
+ case GoalKind.Hold:
+ Assert.That(unit.MoveCellX, Is.EqualTo(-1), where + " — Hold must say nothing about movement");
+ Assert.That(unit.MoveCellY, Is.EqualTo(-1), where);
+ break;
+
+ case GoalKind.Advance:
+ Assert.That(unit.MoveCellX, Is.EqualTo(army.StagingCellX), where);
+ Assert.That(unit.MoveCellY, Is.EqualTo(army.StagingCellY), where);
+ Assert.That(unit.AttackTargetRaw, Is.EqualTo(0u),
+ where + " — a reinforcement on its way must not carry an explicit target (F001)");
+ break;
+
+ case GoalKind.Retreat:
+ Assert.That(unit.MoveCellX, Is.EqualTo(army.StagingCellX), where);
+ Assert.That(unit.MoveCellY, Is.EqualTo(army.StagingCellY), where);
+ break;
+
+ default:
+ Assert.Fail(where + " — unnamed goal");
+ break;
+ }
+ }
+ }
+
+ ///
+ /// A unit under the retreat rule is reported as Retreat, and the
+ /// numbers reported beside it are the ones the rule compared.
+ ///
+ /// The setup is the one from
+ /// SkirmishAi_PullsWoundedUnitsBackTowardTheirOwnBase: the wave
+ /// has to be out, an ARMED enemy has to stand inside the danger radius,
+ /// and the wound is written into the state rather than shot in — this
+ /// asks what the AI decides about a wounded unit, not whether a rifle
+ /// can hit one.
+ ///
+ ///
+ [Test]
+ public void AWoundedUnitUnderFireIsReportedAsRetreat_WithTheNumbersTheRuleWeighed()
+ {
+ AiProfile shipped = AiProfiles.Ms1Canonical;
+ var observer = new RecordingObserver();
+ SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, goalObserver: observer);
+ int ring = shipped.StagingDistanceCells + shipped.StagingToleranceCells;
+
+ Assert.That(TryHqCell(host, AiSlot, out int hqX, out int hqY), Is.True);
+
+ int budget = SkirmishAiTests.EndToEndBudgetTicks;
+ while (budget-- > 0 && FarthestCombatDistance(host, AiSlot, hqX, hqY) <= ring) host.Step();
+ Assert.That(FarthestCombatDistance(host, AiSlot, hqX, hqY), Is.GreaterThan(ring),
+ "the army never marched, so nothing could turn back");
+
+ Assert.That(TryFirstCombatUnit(host, AiSlot, out EntityId woundedId, out int armyX, out int armyY),
+ Is.True);
+ Assert.That(host.Entities.TryGetUnit(woundedId, out UnitState marching), Is.True);
+ Assert.That(marching.TargetGridPos.IsValid, Is.True, "the subject has to be marching somewhere");
+
+ int aheadX = armyX + Math.Sign(marching.TargetGridPos.X - armyX) * shipped.RetreatDangerCells;
+ int aheadY = armyY + Math.Sign(marching.TargetGridPos.Y - armyY) * shipped.RetreatDangerCells;
+ SpawnEnemyInfantry(host, aheadX, aheadY);
+
+ ref UnitState target = ref host.Entities.GetUnitRef(woundedId);
+ target.CurrentHealth = target.MaxHealth * (shipped.RetreatHealthPercent - 20) / 100;
+
+ uint woundedRaw = UnitCommandStateView.ToRawEntityId(woundedId);
+ int before = observer.Units.Count;
+ RunToNextDecision(host);
+
+ bool seen = false;
+ for (int i = before; i < observer.Units.Count; i++)
+ {
+ AiUnitGoal goal = observer.Units[i].Goal;
+ if (goal.EntityRaw != woundedRaw) continue;
+ seen = true;
+
+ Assert.That(goal.Goal, Is.EqualTo(GoalKind.Retreat),
+ "a wounded unit with an armed enemy beside it was not reported as retreating");
+ Assert.That(goal.HealthPercent, Is.LessThan(shipped.RetreatHealthPercent),
+ "the reported health is not the one that put the unit under the rule");
+ Assert.That(goal.ThreatDistanceCells, Is.InRange(0, shipped.RetreatDangerCells),
+ "the reported threat distance does not explain why the rule fired");
+ break;
+ }
+ Assert.That(seen, Is.True, "the wounded unit was never judged after the wound");
+ }
+
+ // ----------------------------------------------------------------
+ // The two seams cost the shipped game nothing
+ // ----------------------------------------------------------------
+
+ ///
+ /// WATCHING IS NOT PLAYING. A run with an observer attached reaches the
+ /// same end state on the same tick as one without — otherwise the panel
+ /// would be describing a match that only exists while somebody looks at
+ /// it.
+ ///
+ [Test]
+ public void AnObserverDoesNotMoveTheMatch()
+ {
+ SkirmishAiTests.AiHost plain = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed);
+ uint plainDecided = plain.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks);
+
+ SkirmishAiTests.AiHost watched = SkirmishAiTests.BuildMatch(
+ SkirmishAiTests.Seed, goalObserver: new RecordingObserver());
+ uint watchedDecided = watched.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(watchedDecided, Is.EqualTo(plainDecided));
+ Assert.That(watched.Kernel.CalculateStateHash(), Is.EqualTo(plain.Kernel.CalculateStateHash()),
+ "attaching an observer changed the match");
+ });
+ }
+
+ ///
+ /// An EMPTY mask is the off setting, and it has to be bit-exactly off:
+ /// for every unit means "the AI decides",
+ /// which is what the shipped game does by passing no mask at all.
+ ///
+ [Test]
+ public void AnEmptyGoalMaskDoesNotMoveTheMatch()
+ {
+ SkirmishAiTests.AiHost plain = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed);
+ uint plainDecided = plain.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks);
+
+ SkirmishAiTests.AiHost masked = SkirmishAiTests.BuildMatch(
+ SkirmishAiTests.Seed, goalOverride: new FixedMask(GoalKind.None));
+ uint maskedDecided = masked.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(maskedDecided, Is.EqualTo(plainDecided));
+ Assert.That(masked.Kernel.CalculateStateHash(), Is.EqualTo(plain.Kernel.CalculateStateHash()),
+ "a mask that names nothing still changed the match");
+ });
+ }
+
+ // ----------------------------------------------------------------
+ // …and a mask that names something is visible in the match
+ // ----------------------------------------------------------------
+
+ ///
+ /// A mask that holds every unit keeps the whole army inside the staging
+ /// ring for the entire match.
+ ///
+ /// Asserted on POSITIONS, not on the absence of intents: what the
+ /// override is for is being able to see the consequence of a goal, and
+ /// the consequence of Hold is that nobody goes anywhere.
+ ///
+ ///
+ /// THE CONTROL RUNS IN THE SAME TEST, on the same seed and the same
+ /// budget, because "the army stayed home" is the kind of assertion that
+ /// passes just as happily when nothing was built, when the match ended
+ /// early, or when the ring was measured against the wrong cell. Without
+ /// the unmasked half beside it, this test proves that a number is small.
+ ///
+ ///
+ [Test]
+ public void AMaskThatHoldsEveryUnitKeepsTheArmyInTheRing()
+ {
+ AiProfile shipped = AiProfiles.Ms1Canonical;
+ int ring = shipped.StagingDistanceCells + shipped.StagingToleranceCells;
+
+ SkirmishAiTests.AiHost loose = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed);
+ loose.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks);
+ Assert.That(TryHqCell(loose, AiSlot, out int looseX, out int looseY), Is.True);
+ Assert.That(FarthestCombatDistance(loose, AiSlot, looseX, looseY), Is.GreaterThan(ring),
+ "the control run never left the ring either, so holding proves nothing");
+
+ SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(
+ SkirmishAiTests.Seed, goalOverride: new FixedMask(GoalKind.Hold));
+ host.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks);
+
+ Assert.That(TryHqCell(host, AiSlot, out int hqX, out int hqY), Is.True);
+ Assert.That(CountCombatUnits(host, AiSlot), Is.GreaterThan(0),
+ "nothing was produced, so 'nobody left' says nothing");
+ Assert.That(FarthestCombatDistance(host, AiSlot, hqX, hqY), Is.LessThanOrEqualTo(ring),
+ "a unit left the staging ring although every unit was held");
+ }
+
+ ///
+ /// A forced goal produces the ORDERS OF THAT GOAL and is reported as
+ /// forced. The mask replaces the pick, never the effect — so a panel
+ /// cannot conjure a behaviour the AI has no code for.
+ ///
+ [Test]
+ public void AForcedGoalProducesTheOrdersOfThatGoal_AndSaysItWasForced()
+ {
+ var observer = new RecordingObserver();
+ SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(
+ SkirmishAiTests.Seed,
+ goalObserver: observer,
+ goalOverride: new FixedMask(GoalKind.Advance));
+ host.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks);
+
+ Assert.That(observer.Units, Is.Not.Empty, "no unit decision was reported at all");
+ for (int i = 0; i < observer.Units.Count; i++)
+ {
+ UnitEntry entry = observer.Units[i];
+ AiUnitGoal unit = entry.Goal;
+ AiArmyGoal army = observer.ArmyAt(entry.Tick);
+
+ Assert.That(unit.Goal, Is.EqualTo(GoalKind.Advance),
+ $"unit {unit.EntityRaw} at tick {entry.Tick} kept its own goal against the mask");
+ Assert.That(unit.Forced, Is.True, "the report does not admit that the goal was forced");
+ Assert.That(unit.MoveCellX, Is.EqualTo(army.StagingCellX));
+ Assert.That(unit.MoveCellY, Is.EqualTo(army.StagingCellY));
+ }
+ }
+
+ // ----------------------------------------------------------------
+ // The army report
+ // ----------------------------------------------------------------
+
+ ///
+ /// The wave verdict and the numbers reported beside it are the SAME
+ /// arithmetic: what the ring holds against the threshold it is measured
+ /// with. That is what lets a panel say "another 140 points" instead of
+ /// repeating the gate's rules in a second language and getting them
+ /// subtly wrong — which is exactly what the recorded player did before
+ /// these numbers existed, and it had to label every one of them derived.
+ ///
+ [Test]
+ public void TheArmyReportExplainsItsOwnWaveVerdict()
+ {
+ var observer = new RecordingObserver();
+ SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, goalObserver: observer);
+ host.RunUntilDecided(SkirmishAiTests.EndToEndBudgetTicks);
+
+ bool weighed = false;
+ for (int i = 0; i < observer.Army.Count; i++)
+ {
+ AiArmyGoal army = observer.Army[i].Goal;
+ if (!army.Engages) continue;
+
+ switch (army.WaveMode)
+ {
+ case WaveGateMode.Strength:
+ weighed = true;
+ Assert.That(army.WaveReady, Is.EqualTo(army.GatheredStrength >= army.WaveThreshold),
+ $"tick {observer.Army[i].Tick}: the verdict does not follow from the reported numbers");
+ break;
+
+ case WaveGateMode.Count:
+ Assert.That(army.WaveReady, Is.EqualTo(army.Gathered >= army.WaveThreshold),
+ $"tick {observer.Army[i].Tick}: the verdict does not follow from the reported numbers");
+ break;
+
+ default:
+ Assert.That(army.WaveReady, Is.True, "waves are off, so every unit is its own wave");
+ break;
+ }
+ }
+
+ Assert.That(weighed, Is.True,
+ "the shipped profile measures the wave in strength, and no such decision was reported");
+ }
+
+ // ================================================================
+ // DefendHome (r8) — breaking off the gathering when the base burns
+ //
+ // The five checks VERTEIDIGUNG.md asks for, and the reason each of
+ // them exists is a way the rule could pass while being wrong. Test 2
+ // in particular: without it, "breaks off" and "attacks early" are
+ // both green, and only one of them is the rule.
+ // ================================================================
+
+ ///
+ /// AN ARMED ENEMY AT THE BASE PUTS THE GATHERERS UNDER
+ /// , and their orders point home.
+ ///
+ /// Asserted over the ORDER THAT WENT OUT and not only over the reported
+ /// goal: a name in the recording that no unit acts on would be the
+ /// panel lying in a new place.
+ ///
+ ///
+ [Test]
+ public void AnArmedEnemyAtTheBaseTurnsTheGatherersIntoDefenders()
+ {
+ var observer = new RecordingObserver();
+ SkirmishAiTests.AiHost host = GatheringHost(AiProfiles.Ms1Canonical, observer, out int hqX, out int hqY);
+
+ 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;
+ if (goal.Goal != GoalKind.DefendHome) continue;
+ defenders++;
+ Assert.That(goal.MoveCellX, Is.EqualTo(hqX), "a defender was not sent to the headquarters");
+ Assert.That(goal.MoveCellY, Is.EqualTo(hqY), "a defender was not sent to the headquarters");
+ Assert.That(goal.AttackTargetRaw, Is.Not.Zero,
+ "a defender walks home carrying no target — finding F001, it would fire at nothing");
+ }
+ Assert.That(defenders, Is.GreaterThan(0),
+ "the headquarters is under attack and not one waiting unit broke off");
+
+ // And the standing order really is the one the goal names.
+ Assert.That(AnyCombatUnitOrderedTo(host, AiSlot, hqX, hqY), Is.True,
+ "no unit actually carries the march order the recording claims");
+ }
+
+ ///
+ /// THE WAVE IS INTERRUPTED, NOT RELEASED. No gatherer is sent toward
+ /// the army's target.
+ ///
+ /// WITHOUT THIS TEST BOTH BEHAVIOURS ARE GREEN. "Everyone marches out
+ /// early" also empties the staging ring and also ends with units
+ /// fighting, and it is the opposite of the rule: it takes the defenders
+ /// AWAY from the base that is being shot.
+ ///
+ ///
+ [Test]
+ public void TheDefenceDoesNotSendTheWaveOffEarly()
+ {
+ var observer = new RecordingObserver();
+ SkirmishAiTests.AiHost host = GatheringHost(AiProfiles.Ms1Canonical, observer, out int hqX, out int hqY);
+
+ SpawnEnemyInfantry(host, hqX + 2, hqY);
+ int before = observer.Units.Count;
+ RunToNextDecision(host);
+
+ for (int i = before; i < observer.Units.Count; i++)
+ {
+ AiUnitGoal goal = observer.Units[i].Goal;
+ if (goal.Goal != GoalKind.DefendHome) continue;
+
+ // Every step toward the enemy start area is a step away from
+ // the fight at home, so the only acceptable destination is the
+ // headquarters itself.
+ Assert.That(Math.Max(Math.Abs(goal.MoveCellX - hqX), Math.Abs(goal.MoveCellY - hqY)),
+ Is.Zero,
+ $"unit {goal.EntityRaw} is under DefendHome and walking to " +
+ $"{goal.MoveCellX},{goal.MoveCellY} instead of home at {hqX},{hqY}");
+ }
+ }
+
+ ///
+ /// COMMITTED STAYS COMMITTED. A wave that is already out is not called
+ /// back — the r3 rule that made a wave a wave, and the V002 failure
+ /// mode if it fell through the back door.
+ ///
+ [Test]
+ public void AWaveThatIsAlreadyOutIsNotCalledBack()
+ {
+ AiProfile shipped = AiProfiles.Ms1Canonical;
+ int ring = shipped.StagingDistanceCells + shipped.StagingToleranceCells;
+
+ var observer = new RecordingObserver();
+ SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, goalObserver: observer);
+ Assert.That(TryHqCell(host, AiSlot, out int hqX, out int hqY), Is.True);
+
+ int budget = SkirmishAiTests.EndToEndBudgetTicks;
+ while (budget-- > 0 && FarthestCombatDistance(host, AiSlot, hqX, hqY) <= ring) host.Step();
+ Assert.That(FarthestCombatDistance(host, AiSlot, hqX, hqY), Is.GreaterThan(ring),
+ "the army never marched, so nothing could be called back");
+
+ SpawnEnemyInfantry(host, hqX + 2, hqY);
+ int before = observer.Units.Count;
+ RunToNextDecision(host);
+
+ bool judgedSomebodyOutside = false;
+ for (int i = before; i < observer.Units.Count; i++)
+ {
+ AiUnitGoal goal = observer.Units[i].Goal;
+ if (goal.HomeDistanceCells <= ring) continue;
+ judgedSomebodyOutside = true;
+ Assert.That(goal.Goal, Is.Not.EqualTo(GoalKind.DefendHome),
+ $"unit {goal.EntityRaw} is {goal.HomeDistanceCells} cells out, past the ring at " +
+ $"{ring}, and the defence called it back");
+ }
+ Assert.That(judgedSomebodyOutside, Is.True, "no unit was outside the ring when the enemy arrived");
+ }
+
+ ///
+ /// THE OFF SETTING IS OFF. With defendHomeCells: 0 the same scene
+ /// produces no defender at all.
+ ///
+ /// That the off path is bit-identical to r7 is a claim about two
+ /// BUILDS and cannot be asserted from inside one — it is measured in
+ /// the lab (`compare` against `defend-off`, and the hash chain of the
+ /// canonical match). What belongs here is the half that is checkable:
+ /// zero means the rule cannot fire, which is what makes the one-sided
+ /// measurement mean anything at all (finding M001).
+ ///
+ ///
+ [Test]
+ public void WithTheRuleOffNobodyDefends()
+ {
+ var observer = new RecordingObserver();
+ SkirmishAiTests.AiHost host = GatheringHost(DefenceOff(), observer, out int hqX, out int hqY);
+
+ SpawnEnemyInfantry(host, hqX + 2, hqY);
+ int before = observer.Units.Count;
+ RunToNextDecision(host);
+
+ for (int i = before; i < observer.Units.Count; i++)
+ {
+ Assert.That(observer.Units[i].Goal.Goal, Is.Not.EqualTo(GoalKind.DefendHome),
+ "defendHomeCells is 0 and a unit was still put under DefendHome");
+ }
+ for (int i = before; i < observer.Army.Count; i++)
+ {
+ Assert.That(observer.Army[i].Goal.HomeThreatened, Is.False,
+ "defendHomeCells is 0 and the posture still reports the base as threatened");
+ }
+ }
+
+ ///
+ /// NO COMMAND STREAM — the test V002 did not have.
+ ///
+ /// An unchanged situation over several cadences must not produce a
+ /// second order. That is the whole reason the destination is the
+ /// headquarters, a cell that does not move: DefendBase aimed at
+ /// the enemy, handed every unit a fresh destination every cadence, and
+ /// died of 23 % more intents. The enemy here is deliberately left
+ /// standing so the trigger holds while the defenders arrive.
+ ///
+ ///
+ [Test]
+ public void AHeldDefenceDoesNotProduceAnOrderEveryCadence()
+ {
+ SkirmishAiTests.AiHost host = GatheringHost(AiProfiles.Ms1Canonical, null, out int hqX, out int hqY);
+
+ SpawnEnemyInfantry(host, hqX + 2, hqY);
+ RunToNextDecision(host); // the decision that turns them around
+
+ int cadence = host.Ai.DecisionTickInterval;
+ int afterTurn = host.IntentsSubmitted;
+ for (int i = 0; i < 5; i++) RunToNextDecision(host);
+
+ int perCadence = (host.IntentsSubmitted - afterTurn) / 5;
+ Assert.That(perCadence, Is.LessThanOrEqualTo(2),
+ $"five cadences of an unchanged defence cost {host.IntentsSubmitted - afterTurn} intents " +
+ $"({perCadence} per cadence of {cadence} ticks) — a static destination must be suppressed " +
+ "after the first order, and this is the shape DefendBase died of (journal V002)");
+ }
+
+ ///
+ /// A DEFENDER THAT HAS ARRIVED IS NOT SENT HOME AGAIN. Over ten cadences
+ /// of an unchanged siege, a unit standing at the base with no march
+ /// order still has none afterwards.
+ ///
+ /// THE INTENT COUNT COULD NOT SEE THIS, which is why the test beside it
+ /// was not enough. Every defender shares one destination, so the repeat
+ /// costs ONE grouped intent per cadence — inside the tolerance the count
+ /// test allows, and invisible next to the economy's own traffic. The
+ /// defect is only visible on the unit: MovementSystem calls
+ /// UnitState.Stop() on arrival, which clears the very field the
+ /// re-issue suppression compares, so "the destination is static" stops
+ /// protecting anything the moment somebody gets there. Measured before
+ /// the fix: eight standing units re-ordered every cadence, for as long
+ /// as the siege lasted.
+ ///
+ ///
+ /// Asserted on the STANDING ORDER and not on a goal report: the goal is
+ /// still DefendHome either way — a defender at the base IS
+ /// defending — and what changed is the order it produces.
+ ///
+ ///
+ [Test]
+ public void AnArrivedDefenderIsNotSentHomeAgain()
+ {
+ AiProfile shipped = AiProfiles.Ms1Canonical;
+ var observer = new RecordingObserver();
+ SkirmishAiTests.AiHost host = GatheringHost(shipped, observer, out int hqX, out int hqY);
+
+ // The trigger has to survive TWO cadences — one to turn the army
+ // around, one to judge the subject — and twelve defenders kill a
+ // shipped-health intruder inside the first.
+ SpawnEnemyInfantry(host, hqX + 2, hqY, maxHealth: 100_000_000);
+ RunToNextDecision(host); // the defence fires and the gatherers turn around
+
+ // The subject is now PUT where a defender is two cadences later:
+ // at the headquarters, stopped. Placed rather than walked there on
+ // purpose — a real siege kills units, the army drops under its
+ // squad threshold, the whole army step stops running, and then
+ // everything stands still for a reason that has nothing to do with
+ // this rule. That scene passes while the defect is fully present.
+ //
+ // It is placed AFTER a decision has landed, not before: an intent
+ // sealed at the previous cadence arrives a tick later and would put
+ // the standing order straight back.
+ Assert.That(TryFirstCombatUnit(host, AiSlot, out EntityId subjectId, out _, out _), Is.True);
+ ref UnitState parked = ref host.Entities.GetUnitRef(subjectId);
+ parked.Transform = new Transform2D(SimFixed.FromInt(hqX), SimFixed.FromInt(hqY));
+ parked.Stop();
+
+ uint subjectRaw = UnitCommandStateView.ToRawEntityId(subjectId);
+ int before = observer.Units.Count;
+ RunToNextDecision(host);
+
+ bool seen = false;
+ for (int i = before; i < observer.Units.Count; i++)
+ {
+ AiUnitGoal goal = observer.Units[i].Goal;
+ if (goal.EntityRaw != subjectRaw) continue;
+ seen = true;
+
+ Assert.That(goal.Goal, Is.EqualTo(GoalKind.DefendHome),
+ "the subject stands at a headquarters under attack and is not defending it");
+ Assert.That(goal.MoveCellX, Is.LessThan(0),
+ "a defender that is already home was sent home AGAIN. The march order is suppressed "
+ + "by comparing UnitState.TargetGridPos, and MovementSystem CLEARS that field on "
+ + "arrival — so 'the destination is static' stops protecting anything the moment "
+ + "somebody gets there, and the headquarters cell goes out every cadence for the "
+ + "whole siege (journal V002 is this shape, one size down). DefendHome has to fall "
+ + "silent itself, the way Hold does at the staging cell");
+ Assert.That(goal.AttackTargetRaw, Is.Not.Zero,
+ "silence about WALKING must not become silence about SHOOTING — a defender at the "
+ + "base still needs a target");
+ break;
+ }
+ Assert.That(seen, Is.True, "the subject was never judged");
+
+ // And the silence is about being home, not about the goal being
+ // inert: the gatherers twelve cells out are still ordered in.
+ Assert.That(AnyCombatUnitOrderedTo(host, AiSlot, hqX, hqY), Is.True,
+ "no unit at all was ordered home, so the scene never exercised the rule");
+
+ // Finally the world, not the report: nothing was submitted for it.
+ Assert.That(host.Entities.TryGetUnit(subjectId, out UnitState after), Is.True);
+ Assert.That(after.TargetGridPos.IsValid, Is.False,
+ "the subject carries a march order again, so an intent went out for a unit that was "
+ + "already standing where it was being sent");
+ }
+
+ ///
+ /// A WOUNDED UNIT THAT IS ALREADY HOME DEFENDS LIKE ANYBODY ELSE.
+ ///
+ /// The rule used to ask not to be retreating, and that clause could only
+ /// ever catch a unit that had ARRIVED — one still running is taken by
+ /// Retreat one line earlier. But arriving ENDS the retreat by the
+ /// AI's own rule (MS-1 units never heal, so a unit that stayed under
+ /// Retreat until it recovered would occupy the army cap forever).
+ /// So the clause did nothing except hand the units who were already back
+ /// at the gathering point to Hold: standing twelve cells out,
+ /// aiming at a pursuer they could not reach, while the base they had run
+ /// to was being shot.
+ ///
+ ///
+ [Test]
+ public void AWoundedUnitThatIsAlreadyHomeDefendsInsteadOfHolding()
+ {
+ AiProfile shipped = AiProfiles.Ms1Canonical;
+ var observer = new RecordingObserver();
+ SkirmishAiTests.AiHost host = GatheringHost(shipped, observer, out int hqX, out int hqY);
+
+ Assert.That(observer.Army.Count, Is.GreaterThan(0), "the army never reported a posture");
+ AiArmyGoal army = observer.Army[observer.Army.Count - 1].Goal;
+ Assert.That(army.StagingCellX, Is.GreaterThanOrEqualTo(0), "no staging cell was resolved");
+
+ // The subject: parked AT the staging cell and standing, so it counts
+ // as arrived and its retreat is over.
+ Assert.That(TryFirstCombatUnit(host, AiSlot, out EntityId subjectId, out _, out _), Is.True);
+ ref UnitState subject = ref host.Entities.GetUnitRef(subjectId);
+ subject.Transform = new Transform2D(
+ SimFixed.FromInt(army.StagingCellX), SimFixed.FromInt(army.StagingCellY));
+ subject.Stop();
+ subject.CurrentHealth = subject.MaxHealth * (shipped.RetreatHealthPercent - 20) / 100;
+
+ // One enemy that satisfies BOTH halves at once: inside the defence
+ // radius of the headquarters, and inside the danger radius of the
+ // subject — which is what makes it a wounded unit that is home AND
+ // under the retreat rule, the only case the dropped clause touched.
+ int enemyX = (hqX + army.StagingCellX) / 2;
+ int enemyY = (hqY + army.StagingCellY) / 2;
+ Assert.That(Math.Max(Math.Abs(enemyX - hqX), Math.Abs(enemyY - hqY)),
+ Is.LessThanOrEqualTo(shipped.DefendHomeCells), "the enemy does not threaten the base");
+ Assert.That(
+ Math.Max(Math.Abs(enemyX - army.StagingCellX), Math.Abs(enemyY - army.StagingCellY)),
+ Is.LessThanOrEqualTo(shipped.RetreatDangerCells), "the enemy does not endanger the subject");
+ SpawnEnemyInfantry(host, enemyX, enemyY, maxHealth: 100_000_000);
+
+ uint subjectRaw = UnitCommandStateView.ToRawEntityId(subjectId);
+ int before = observer.Units.Count;
+ RunToNextDecision(host);
+
+ bool seen = false;
+ for (int i = before; i < observer.Units.Count; i++)
+ {
+ AiUnitGoal goal = observer.Units[i].Goal;
+ if (goal.EntityRaw != subjectRaw) continue;
+ seen = true;
+
+ Assert.That(goal.Goal, Is.EqualTo(GoalKind.DefendHome),
+ $"a wounded unit that is already home was reported as {goal.Goal} while the base is "
+ + "under attack — arriving ends the retreat, so it is an ordinary defender");
+ Assert.That(goal.MoveCellX, Is.EqualTo(hqX), "the defender was not sent to the headquarters");
+ Assert.That(goal.MoveCellY, Is.EqualTo(hqY), "the defender was not sent to the headquarters");
+ Assert.That(goal.HealthPercent, Is.LessThan(shipped.RetreatHealthPercent),
+ "the subject is not actually under the retreat threshold, so it proves nothing");
+ break;
+ }
+ Assert.That(seen, Is.True, "the subject was never judged");
+ }
+
+ // ----------------------------------------------------------------
+ // Deterministic read helpers (ascending entity index)
+ // ----------------------------------------------------------------
+
+ /// The shipped profile with the defence switched off, and nothing else changed.
+ private static AiProfile DefenceOff()
+ {
+ AiProfile s = AiProfiles.Ms1Canonical;
+ return new AiProfile(
+ profileId: "defence-off-probe",
+ decisionTickInterval: s.DecisionTickInterval,
+ placementSearchRadius: s.PlacementSearchRadius,
+ powerReserve: s.PowerReserve,
+ targetHarvesters: s.TargetHarvesters,
+ harvesterQueueBatch: s.HarvesterQueueBatch,
+ targetArmySize: s.TargetArmySize,
+ attackSquadThreshold: s.AttackSquadThreshold,
+ infantryQueueBatch: s.InfantryQueueBatch,
+ targetDamageWeight: s.TargetDamageWeight,
+ targetThreatWeight: s.TargetThreatWeight,
+ targetFinishWeight: s.TargetFinishWeight,
+ targetDistanceWeight: s.TargetDistanceWeight,
+ waveSize: s.WaveSize,
+ stagingDistanceCells: s.StagingDistanceCells,
+ stagingToleranceCells: s.StagingToleranceCells,
+ retreatHealthPercent: s.RetreatHealthPercent,
+ retreatDangerCells: s.RetreatDangerCells,
+ waveStrengthPoints: s.WaveStrengthPoints,
+ defendHomeCells: 0);
+ }
+
+ ///
+ /// A host run forward to the point where the army is GATHERING: it acts
+ /// (so the army step judges anybody at all) and its units are still
+ /// inside the staging ring (so there is something to break off).
+ ///
+ private static SkirmishAiTests.AiHost GatheringHost(
+ AiProfile profile, RecordingObserver observer, out int hqX, out int hqY)
+ {
+ int ring = profile.StagingDistanceCells + profile.StagingToleranceCells;
+ SkirmishAiTests.AiHost host =
+ SkirmishAiTests.BuildMatch(SkirmishAiTests.Seed, profile, goalObserver: observer);
+
+ Assert.That(TryHqCell(host, AiSlot, out hqX, out hqY), Is.True);
+
+ int budget = SkirmishAiTests.EndToEndBudgetTicks;
+ while (budget-- > 0
+ && (CountCombatUnits(host, AiSlot) < profile.AttackSquadThreshold
+ || FarthestCombatDistance(host, AiSlot, hqX, hqY) > ring))
+ {
+ host.Step();
+ }
+
+ Assert.That(CountCombatUnits(host, AiSlot), Is.GreaterThanOrEqualTo(profile.AttackSquadThreshold),
+ "the army never reached its squad threshold, so no goal is handed out at all");
+ Assert.That(FarthestCombatDistance(host, AiSlot, hqX, hqY), Is.LessThanOrEqualTo(ring),
+ "no unit is gathering inside the ring, so there is nothing to break off");
+ return host;
+ }
+
+ /// Whether any combat unit of the seat carries a march order to this cell.
+ private static bool AnyCombatUnitOrderedTo(
+ SkirmishAiTests.AiHost host, byte slot, int cellX, int cellY)
+ {
+ UnitState[] units = host.Entities.RawUnits;
+ for (int i = 0; i < units.Length; i++)
+ {
+ ref readonly UnitState u = ref units[i];
+ if (!u.IsActive || u.PlayerId != slot || !IsCombat(u.Role)) continue;
+ if (!u.TargetGridPos.IsValid) continue;
+ if (u.TargetGridPos.X == cellX && u.TargetGridPos.Y == cellY) return true;
+ }
+ return false;
+ }
+
+ private 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++)
+ {
+ 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;
+ }
+
+ private static int FarthestCombatDistance(SkirmishAiTests.AiHost host, byte slot, int cellX, int cellY)
+ {
+ int farthest = 0;
+ UnitState[] units = host.Entities.RawUnits;
+ for (int i = 0; i < units.Length; i++)
+ {
+ ref readonly UnitState u = ref units[i];
+ if (!u.IsActive || u.PlayerId != slot || !IsCombat(u.Role)) continue;
+ int distance = Math.Max(
+ Math.Abs(SimFixed.WorldToGrid(u.Transform.PositionX) - cellX),
+ Math.Abs(SimFixed.WorldToGrid(u.Transform.PositionY) - cellY));
+ if (distance > farthest) farthest = distance;
+ }
+ return farthest;
+ }
+
+ private static int CountCombatUnits(SkirmishAiTests.AiHost host, byte slot)
+ {
+ int count = 0;
+ UnitState[] units = host.Entities.RawUnits;
+ for (int i = 0; i < units.Length; i++)
+ {
+ ref readonly UnitState u = ref units[i];
+ if (u.IsActive && u.PlayerId == slot && IsCombat(u.Role)) count++;
+ }
+ return count;
+ }
+
+ private static bool TryFirstCombatUnit(
+ SkirmishAiTests.AiHost host, byte slot, out EntityId id, out int cellX, out int cellY)
+ {
+ UnitState[] units = host.Entities.RawUnits;
+ for (int i = 0; i < units.Length; i++)
+ {
+ ref readonly UnitState u = ref units[i];
+ if (!u.IsActive || u.PlayerId != slot || !IsCombat(u.Role)) continue;
+ id = u.Id;
+ cellX = SimFixed.WorldToGrid(u.Transform.PositionX);
+ cellY = SimFixed.WorldToGrid(u.Transform.PositionY);
+ return true;
+ }
+ id = EntityId.Invalid;
+ cellX = -1;
+ cellY = -1;
+ return false;
+ }
+
+ ///
+ /// An armed enemy of the passive seat at a cell.
+ ///
+ /// exists for the tests that need the
+ /// TRIGGER TO HOLD over many cadences: with the shipped health the
+ /// defenders kill the intruder within two or three of them, and a test
+ /// about what a standing defence costs would then be measuring the quiet
+ /// after the fight. An enemy that cannot be killed is not a claim about
+ /// the game, it is a way to keep one condition true while another is
+ /// counted.
+ ///
+ ///
+ private static void SpawnEnemyInfantry(
+ SkirmishAiTests.AiHost host, int cellX, int cellY, int maxHealth = 0)
+ {
+ const byte enemySlot = 0;
+ FactionId faction = host.Economy.GetSlotFaction(enemySlot);
+ Assert.That(SimDefinitions.TryGetUnit(faction, UnitRole.BasicInfantry, out SimUnitDefinition def), Is.True);
+ host.Entities.SpawnUnit(
+ enemySlot,
+ new Transform2D(SimFixed.FromInt(cellX), SimFixed.FromInt(cellY)),
+ def.MoveSpeed,
+ maxHealth: maxHealth > 0 ? maxHealth : def.MaxHealth,
+ role: UnitRole.BasicInfantry);
+ }
+
+
+ ///
+ /// To the next decision cadence and two ticks further, so the sealed
+ /// intent has landed. Not further: the subject keeps walking, and a long
+ /// window lets it leave the danger radius on its own.
+ ///
+ private static void RunToNextDecision(SkirmishAiTests.AiHost host)
+ {
+ ushort cadence = host.Ai.DecisionTickInterval;
+ do
+ {
+ host.Step();
+ }
+ while (host.Kernel.CurrentTick.Value % cadence != 0);
+ host.Step();
+ host.Step();
+ }
+
+ private static bool IsCombat(UnitRole role) =>
+ role >= UnitRole.BasicInfantry && role <= UnitRole.Artillery;
+ }
+}