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; + } +}