diff --git a/Assets/Tests/EditMode/AI/Nova.AI.Tests.asmdef b/Assets/Tests/EditMode/AI/Nova.AI.Tests.asmdef
index 736af340..4c6c36de 100644
--- a/Assets/Tests/EditMode/AI/Nova.AI.Tests.asmdef
+++ b/Assets/Tests/EditMode/AI/Nova.AI.Tests.asmdef
@@ -6,7 +6,8 @@
"UnityEditor.TestRunner",
"Nova.Core",
"Nova.Simulation",
- "Nova.AI"
+ "Nova.AI",
+ "Nova.AI.Data"
],
"includePlatforms": [
"Editor"
diff --git a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs
index 18d9904b..b5abf61f 100644
--- a/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs
+++ b/Assets/_Project/Scripts/AI.Data/AiBehaviorId.cs
@@ -156,8 +156,22 @@ public static class AiBehaviorId
/// 15-point margin covered the Barracks' 10-point draw and therefore
/// retried an illegal placement forever once the all-of gate shipped.
///
+ ///
+ /// r9 is the per-match personality (): an
+ /// opening order (Balanced, or Rush with the Barracks first, Economy,
+ /// Turtle with defence platforms toward the front), a flank offset of
+ /// the staging cell, an orientation of the placement scan and banded
+ /// number deltas on army size, squad threshold, harvesters, retreat and
+ /// home guard. It is derived purely from (match seed, slot) and fixed
+ /// at construction, so the AI stays stateless. The NEUTRAL personality
+ /// (default) reproduces r8 tick for tick, which is what the
+ /// canonical outcome pin proves; is unchanged
+ /// because is. A match that
+ /// plays a non-neutral personality is still "r9" — the personality
+ /// is printed next to this identifier, not folded into it.
+ ///
///
- public const int Revision = 8;
+ public const int Revision = 9;
///
/// Hash over every value of the shipped profile. Domain-separated like
diff --git a/Assets/_Project/Scripts/AI.Data/AiPersonality.cs b/Assets/_Project/Scripts/AI.Data/AiPersonality.cs
new file mode 100644
index 00000000..88349470
--- /dev/null
+++ b/Assets/_Project/Scripts/AI.Data/AiPersonality.cs
@@ -0,0 +1,240 @@
+using System;
+using System.Text;
+using Nova.Core;
+
+namespace Nova.AI.Data
+{
+ ///
+ /// Which build order the AI opens with. The values are stable: they are
+ /// part of what maps a seed onto.
+ ///
+ public enum AiOpening : byte
+ {
+ /// Refinery, Barracks — the order the AI always played before r9.
+ Balanced = 0,
+
+ /// Barracks before Refinery, a smaller army and an early march.
+ Rush = 1,
+
+ /// A third harvester and a larger army, paid for by a slower start.
+ Economy = 2,
+
+ /// Defence platforms toward the front, a wider home guard, a bigger army.
+ Turtle = 3,
+ }
+
+ ///
+ /// The per-match flavour of the skirmish AI: a small bundle of choices that
+ /// makes two matches against the same profile play differently, derived
+ /// purely from the match seed and the AI's slot.
+ ///
+ /// WHY THIS IS NOT RANDOMNESS INSIDE THE AI. SkirmishAiSystem is
+ /// stateless by design — every decision is a pure function of the tick and
+ /// the committed state, and that is what lets a save/restore reproduce the
+ /// same later intents with no sidecar. A PRNG would be state. The personality
+ /// is therefore an INPUT fixed at construction, exactly like the profile:
+ /// the seed is consumed once, here, by a hash, and the AI only ever reads
+ /// the result. Both peers of a networked match derive the same value from
+ /// the same (seed, slot), so nothing has to be transmitted.
+ ///
+ ///
+ /// default(AiPersonality) IS the neutral personality — Balanced, every
+ /// delta zero — and reproduces the AI exactly as it played before it
+ /// existed (behaviour r8). Tests, the lab and any caller that does not pass
+ /// a personality therefore keep the behaviour they were measured against.
+ ///
+ ///
+ /// Deltas are relative to the profile is given. A
+ /// personality never switches on a rule the base profile has off (retreat 0,
+ /// defend-home 0): a profile that turned a rule off did so to measure
+ /// without it, and a flavour must not undo that.
+ ///
+ ///
+ public readonly struct AiPersonality : IEquatable
+ {
+ /// Domain tag of the derivation hash; bump the suffix if the mapping ever changes.
+ private const string DomainTag = "NOVA-AI-PERSONALITY-V1";
+
+ /// Which build order the AI opens with.
+ public AiOpening Opening { get; }
+
+ /// Sideways shift of the staging cell, perpendicular to the line toward the enemy: -10, 0 or +10 cells.
+ public int FlankOffsetCells { get; }
+
+ /// Orientation of the placement scan inside each ring, 0..7 (bit0 x descending, bit1 y descending, bit2 x outer).
+ public byte LayoutVariant { get; }
+
+ /// Defence platforms the AI builds after its other buildings (Turtle: 1 or 2).
+ public int DefensePlatforms { get; }
+
+ public int ArmySizeDelta { get; }
+ public int SquadThresholdDelta { get; }
+ public int HarvesterDelta { get; }
+ public int RetreatPercentDelta { get; }
+ public int DefendHomeCellsDelta { get; }
+
+ private AiPersonality(
+ AiOpening opening, int flankOffsetCells, byte layoutVariant, int defensePlatforms,
+ int armySizeDelta, int squadThresholdDelta, int harvesterDelta,
+ int retreatPercentDelta, int defendHomeCellsDelta)
+ {
+ Opening = opening;
+ FlankOffsetCells = flankOffsetCells;
+ LayoutVariant = layoutVariant;
+ DefensePlatforms = defensePlatforms;
+ ArmySizeDelta = armySizeDelta;
+ SquadThresholdDelta = squadThresholdDelta;
+ HarvesterDelta = harvesterDelta;
+ RetreatPercentDelta = retreatPercentDelta;
+ DefendHomeCellsDelta = defendHomeCellsDelta;
+ }
+
+ /// The personality that changes nothing.
+ public static AiPersonality Neutral => default;
+
+ /// True for default: Balanced, every delta zero, no flank, layout 0.
+ public bool IsNeutral =>
+ Opening == AiOpening.Balanced && FlankOffsetCells == 0 && LayoutVariant == 0
+ && DefensePlatforms == 0 && ArmySizeDelta == 0 && SquadThresholdDelta == 0
+ && HarvesterDelta == 0 && RetreatPercentDelta == 0 && DefendHomeCellsDelta == 0;
+
+ ///
+ /// Builds a personality from its already-banded choices — the single
+ /// place the per-opening deltas are defined, shared by
+ /// and by tests that need a specific combination.
+ ///
+ /// -1, 0 or +1: army size nudge on top of the opening's own delta.
+ /// -1, 0 or +1: retreat percentage nudge in steps of 5.
+ /// Turtle only: 1 builds a second platform.
+ public static AiPersonality Create(
+ AiOpening opening, int flankOffsetCells, byte layoutVariant,
+ int armyJitter = 0, int retreatJitter = 0, int extraDefensePlatform = 0)
+ {
+ int army = armyJitter, squad = 0, harvesters = 0, retreat = 5 * retreatJitter, home = 0, platforms = 0;
+ switch (opening)
+ {
+ case AiOpening.Rush:
+ army += -4; squad = -2; retreat += -15;
+ break;
+ case AiOpening.Economy:
+ army += 4; harvesters = 1; retreat += 5;
+ break;
+ case AiOpening.Turtle:
+ army += 2; retreat += 5; home = 4; platforms = 1 + (extraDefensePlatform & 1);
+ break;
+ }
+ return new AiPersonality(opening, flankOffsetCells, (byte)(layoutVariant & 7), platforms,
+ army, squad, harvesters, retreat, home);
+ }
+
+ ///
+ /// The personality of the AI in of the match with
+ /// . PURE: the same pair gives the same value
+ /// on every machine, which is what lets both peers of a networked match
+ /// agree without a message.
+ ///
+ /// One hash, independent bit ranges: opening from the low bits, flank
+ /// from the next byte, and so on, so that no field is a function of
+ /// another and 512 seeds cover every combination of the small ones.
+ ///
+ ///
+ public static AiPersonality Derive(ulong matchSeed, byte slot)
+ {
+ byte[] tag = Encoding.ASCII.GetBytes(DomainTag);
+ byte[] bytes = new byte[tag.Length + 9];
+ Array.Copy(tag, bytes, tag.Length);
+ for (int i = 0; i < 8; i++) bytes[tag.Length + i] = (byte)(matchSeed >> (8 * i));
+ bytes[tag.Length + 8] = slot;
+ ulong h = XxHash64.ComputeHash(bytes);
+
+ AiOpening opening = (AiOpening)(h % 4);
+ int flank = (int)(((h >> 8) % 3) - 1) * 10;
+ byte layout = (byte)((h >> 16) % 8);
+ int j = (int)((h >> 24) % 3) - 1;
+ int k = (int)((h >> 32) % 3) - 1;
+ int extraDefense = (int)((h >> 40) & 1);
+ return Create(opening, flank, layout, j, k, extraDefense);
+ }
+
+ ///
+ /// Returns with this personality's deltas
+ /// applied. The neutral personality returns the profile untouched — same
+ /// id, same hash — so the canonical profile stays the canonical profile.
+ ///
+ public AiProfile Apply(AiProfile baseProfile)
+ {
+ if (IsNeutral) return baseProfile;
+
+ int squad = Math.Max(1, baseProfile.AttackSquadThreshold + SquadThresholdDelta);
+ int army = Math.Max(Math.Max(1, squad), baseProfile.TargetArmySize + ArmySizeDelta);
+ int harvesters = Math.Max(1, baseProfile.TargetHarvesters + HarvesterDelta);
+
+ // A rule the base profile has off stays off.
+ int retreat = baseProfile.RetreatHealthPercent;
+ if (retreat > 0) retreat = Math.Min(95, Math.Max(0, retreat + RetreatPercentDelta));
+ int home = baseProfile.DefendHomeCells;
+ if (home > 0) home = Math.Max(0, home + DefendHomeCellsDelta);
+
+ return new AiProfile(
+ profileId: baseProfile.ProfileId + "+" + Opening.ToString().ToLowerInvariant(),
+ decisionTickInterval: baseProfile.DecisionTickInterval,
+ placementSearchRadius: baseProfile.PlacementSearchRadius,
+ powerReserve: baseProfile.PowerReserve,
+ targetHarvesters: harvesters,
+ harvesterQueueBatch: baseProfile.HarvesterQueueBatch,
+ targetArmySize: army,
+ attackSquadThreshold: squad,
+ infantryQueueBatch: baseProfile.InfantryQueueBatch,
+ targetDamageWeight: baseProfile.TargetDamageWeight,
+ targetThreatWeight: baseProfile.TargetThreatWeight,
+ targetFinishWeight: baseProfile.TargetFinishWeight,
+ targetDistanceWeight: baseProfile.TargetDistanceWeight,
+ waveSize: baseProfile.WaveSize,
+ stagingDistanceCells: baseProfile.StagingDistanceCells,
+ stagingToleranceCells: baseProfile.StagingToleranceCells,
+ retreatHealthPercent: retreat,
+ retreatDangerCells: baseProfile.RetreatDangerCells,
+ waveStrengthPoints: baseProfile.WaveStrengthPoints,
+ defendHomeCells: home);
+ }
+
+ /// Compact description for the debug HUD and logs, e.g. Rush, flank +10, layout 5, army 8.
+ public override string ToString()
+ {
+ string flank = FlankOffsetCells > 0 ? "+" + FlankOffsetCells : FlankOffsetCells.ToString();
+ string army = ArmySizeDelta > 0 ? "+" + ArmySizeDelta : ArmySizeDelta.ToString();
+ return IsNeutral
+ ? "Neutral"
+ : $"{Opening}, flank {flank}, layout {LayoutVariant}, army delta {army}";
+ }
+
+ public bool Equals(AiPersonality other) =>
+ Opening == other.Opening && FlankOffsetCells == other.FlankOffsetCells
+ && LayoutVariant == other.LayoutVariant && DefensePlatforms == other.DefensePlatforms
+ && ArmySizeDelta == other.ArmySizeDelta && SquadThresholdDelta == other.SquadThresholdDelta
+ && HarvesterDelta == other.HarvesterDelta && RetreatPercentDelta == other.RetreatPercentDelta
+ && DefendHomeCellsDelta == other.DefendHomeCellsDelta;
+
+ public override bool Equals(object obj) => obj is AiPersonality other && Equals(other);
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ int hash = (int)Opening;
+ hash = (hash * 397) ^ FlankOffsetCells;
+ hash = (hash * 397) ^ LayoutVariant;
+ hash = (hash * 397) ^ DefensePlatforms;
+ hash = (hash * 397) ^ ArmySizeDelta;
+ hash = (hash * 397) ^ SquadThresholdDelta;
+ hash = (hash * 397) ^ HarvesterDelta;
+ hash = (hash * 397) ^ RetreatPercentDelta;
+ hash = (hash * 397) ^ DefendHomeCellsDelta;
+ return hash;
+ }
+ }
+
+ public static bool operator ==(AiPersonality left, AiPersonality right) => left.Equals(right);
+ public static bool operator !=(AiPersonality left, AiPersonality right) => !left.Equals(right);
+ }
+}
diff --git a/Assets/_Project/Scripts/AI.Data/AiPersonality.cs.meta b/Assets/_Project/Scripts/AI.Data/AiPersonality.cs.meta
new file mode 100644
index 00000000..9273da11
--- /dev/null
+++ b/Assets/_Project/Scripts/AI.Data/AiPersonality.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5807c54257564717897d4088a0b84add
diff --git a/Assets/_Project/Scripts/AI/AiFactionProfile.cs b/Assets/_Project/Scripts/AI/AiFactionProfile.cs
index 506feb5d..9f35adfc 100644
--- a/Assets/_Project/Scripts/AI/AiFactionProfile.cs
+++ b/Assets/_Project/Scripts/AI/AiFactionProfile.cs
@@ -91,6 +91,13 @@ public AiFactionProfile(string factionName, AiProfile profile)
Profile = profile;
}
+ ///
+ /// The same faction with 's deltas applied to its numbers.
+ /// The neutral personality returns an equal profile.
+ ///
+ public AiFactionProfile WithPersonality(AiPersonality personality) =>
+ new AiFactionProfile(FactionName, personality.Apply(Profile));
+
///
/// Equality over the faction name AND every number.
///
diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
index 52a67016..65f6398b 100644
--- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
+++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs
@@ -48,8 +48,13 @@ namespace Nova.AI
///
///
/// DECISION LOOP (fixed cadence = 20
- /// ticks = 2.0 s, ascending-index scans only, no PRNG): (1) build order —
- /// Refinery first (no prerequisite since D-077), then the Power plant
+ /// ticks = 2.0 s, ascending-index scans only, no PRNG — the per-match
+ /// is a pure function of match seed and slot,
+ /// fixed at construction like the profile, never drawn during play):
+ /// (1) build order —
+ /// Refinery first (no prerequisite since D-077; a Rush personality builds
+ /// the Barracks first and a Turtle one adds defence platforms at the
+ /// end), then the Power plant
/// required by D-103, then Barracks, one site at a time; Power also
/// preempts whenever the committed margin would drop below the profile
/// reserve, the spot picked by a deterministic
@@ -157,6 +162,14 @@ private struct SiteInfo
///
private readonly IAiGoalOverride _goalOverride;
+ ///
+ /// The per-match flavour (opening order, staging flank, placement scan
+ /// orientation; its number deltas are already folded into the profile
+ /// by ). Default is the
+ /// neutral personality, which plays exactly as r8 did.
+ ///
+ public AiPersonality Personality { get; }
+
public string Name => $"SkirmishAi_{_profile.FactionName}_P{_aiPlayerId}";
public byte AiPlayerId => _aiPlayerId;
@@ -176,6 +189,13 @@ private struct SiteInfo
/// that were fixed before the first tick, which is what lets the run
/// stay reproducible from its inputs.
///
+ ///
+ /// is a third input of the same kind: fixed
+ /// before the first tick, read-only, derived by the caller from (match
+ /// seed, slot) via . The caller also
+ /// applies it to the profile; this argument carries the parts a number
+ /// cannot express. The neutral default reproduces r8 tick for tick.
+ ///
///
public SkirmishAiSystem(
byte aiPlayerId,
@@ -188,8 +208,10 @@ public SkirmishAiSystem(
FogOfWarSystem fogOfWar,
VictorySystem victory,
IAiGoalObserver goalObserver = null,
- IAiGoalOverride goalOverride = null)
+ IAiGoalOverride goalOverride = null,
+ AiPersonality personality = default)
{
+ Personality = personality;
_goalObserver = goalObserver;
_goalOverride = goalOverride;
_aiPlayerId = aiPlayerId;
@@ -240,6 +262,7 @@ private void Decide(uint tick)
// One ascending-index scan of the entity store collecting every
// fact the decisions below read (deterministic iteration order).
uint hqRaw = 0, refineryRaw = 0, barracksRaw = 0;
+ int defensePlatforms = 0;
int hqCellX = -1, hqCellY = -1, refineryCellX = -1, refineryCellY = -1;
bool powerCompleted = false;
int builders = 0, harvesters = 0, combatCount = 0;
@@ -294,6 +317,9 @@ private void Decide(uint tick)
case UnitRole.Power:
powerCompleted = true;
break;
+ case UnitRole.DefensePlatform:
+ defensePlatforms++;
+ break;
}
continue;
}
@@ -334,9 +360,7 @@ private void Decide(uint tick)
// go negative" with the demo profile's reserve of 0. ----
if (sites.Count == 0)
{
- UnitRole next = refineryRaw == 0
- ? UnitRole.Refinery
- : (barracksRaw == 0 ? UnitRole.Barracks : UnitRole.Unit);
+ UnitRole next = NextBuildingToPlan(refineryRaw != 0, barracksRaw != 0, defensePlatforms);
if (next != UnitRole.Unit
&& SimDefinitions.TryGetBuilding(faction, next, out SimBuildingDefinition nextDef))
{
@@ -869,6 +893,29 @@ private int EffectiveWaveSize()
return waveSize > _profile.TargetArmySize ? _profile.TargetArmySize : waveSize;
}
+ ///
+ /// The next building of the opening order, or
+ /// for "nothing more to build". Neutral/Balanced/Economy/Turtle:
+ /// Refinery, Barracks, then defence platforms up to
+ /// ; Rush swaps the first two.
+ /// The Power pre-emption in the caller applies to whatever this returns,
+ /// so a Rush AI (Barracks needs Power) builds Power, Barracks, Refinery.
+ ///
+ private UnitRole NextBuildingToPlan(bool hasRefinery, bool hasBarracks, int defensePlatforms)
+ {
+ if (Personality.Opening == AiOpening.Rush)
+ {
+ if (!hasBarracks) return UnitRole.Barracks;
+ if (!hasRefinery) return UnitRole.Refinery;
+ }
+ else
+ {
+ if (!hasRefinery) return UnitRole.Refinery;
+ if (!hasBarracks) return UnitRole.Barracks;
+ }
+ return defensePlatforms < Personality.DefensePlatforms ? UnitRole.DefensePlatform : UnitRole.Unit;
+ }
+
///
/// The staging cell: cells
/// from the own HQ along the straight line toward the enemy start area,
@@ -876,6 +923,16 @@ private int EffectiveWaveSize()
/// cell is the SAME for the whole match — a unit ordered there is not
/// re-ordered on the next cadence.
///
+ /// A personality with a flank offset shifts the cell sideways,
+ /// perpendicular to the line toward the enemy. The offset is a match
+ /// constant and the shift reads no live state, so the cell stays a
+ /// pure function of map and personality — the same every cadence.
+ /// Deliberately no footprint test: one would let a building placed
+ /// later flip the cell mid-match, and the unshifted cell never had one
+ /// either. That the shifted cells stay off the rock is pinned for the
+ /// canonical map by AiPersonalityTests.FlankedStagingCells_AreInsideTheGridAndWalkable.
+ ///
+ ///
/// Integer division truncates, which is deterministic and identical on
/// both machines; that is the only property that matters here.
///
@@ -899,6 +956,13 @@ private void GetStagingCell(int hqCellX, int hqCellY, out int cellX, out int cel
cellX = ClampToGrid(hqCellX + (dx * distance / span));
cellY = ClampToGrid(hqCellY + (dy * distance / span));
+
+ int flank = Personality.FlankOffsetCells;
+ if (flank != 0)
+ {
+ cellX = ClampToGrid(cellX + (-dy * flank / span));
+ cellY = ClampToGrid(cellY + (dx * flank / span));
+ }
}
///
@@ -1587,7 +1651,24 @@ private void TryPlaceBuilding(FactionId faction, UnitRole role, long credits, in
anchorX = fieldX;
anchorY = fieldY;
}
- if (TryFindPlacementSpot(def.DefinitionId, anchorX, anchorY, out int spotX, out int spotY))
+ int spotX, spotY;
+ bool found = false;
+ if (role == UnitRole.DefensePlatform)
+ {
+ // A platform guards the approach, not the economy: search around
+ // the midpoint between the HQ and the staging cell first, and
+ // fall back to the field anchor when that stretch is built up.
+ GetStagingCell(hqCellX, hqCellY, out int stageX, out int stageY);
+ found = TryFindPlacementSpot(def.DefinitionId, (hqCellX + stageX) / 2, (hqCellY + stageY) / 2,
+ out spotX, out spotY);
+ }
+ else
+ {
+ spotX = 0;
+ spotY = 0;
+ }
+ if (!found) found = TryFindPlacementSpot(def.DefinitionId, anchorX, anchorY, out spotX, out spotY);
+ if (found)
{
Submit(new PlaceBuildingPayload(def.DefinitionId, (ushort)spotX, (ushort)spotY));
}
@@ -1595,8 +1676,9 @@ private void TryPlaceBuilding(FactionId faction, UnitRole role, long credits, in
///
/// Deterministic placement search: expanding Chebyshev rings 1..
- /// around the anchor cell, ascending (y, x) inside each ring, and the
- /// first origin that passes
+ /// around the anchor cell, inside each ring in the orientation of
+ /// (variant 0: ascending
+ /// (y, x), the order before r9), and the first origin that passes
/// — the identical state-dependent rules the command executor applies
/// at the target tick — wins. Ring 1 first means the Refinery lands
/// beside the field (harvest AND deposit reach in one cell).
@@ -1604,12 +1686,25 @@ private void TryPlaceBuilding(FactionId faction, UnitRole role, long credits, in
private bool TryFindPlacementSpot(ushort buildingDefId, int anchorX, int anchorY, out int spotX, out int spotY)
{
int f = SimDefinitions.BuildingFootprintCells;
+ // Orientation of the in-ring scan: bit0 x descending, bit1 y descending,
+ // bit2 x is the outer loop. Ring order (d ascending) is untouched.
+ int layout = Personality.LayoutVariant;
+ bool xDesc = (layout & 1) != 0, yDesc = (layout & 2) != 0, xOuter = (layout & 4) != 0;
for (int d = 1; d <= PlacementSearchRadius; d++)
{
- for (int y = anchorY - d - (f - 1); y <= anchorY + d; y++)
+ int xLo = anchorX - d - (f - 1), xHi = anchorX + d;
+ int yLo = anchorY - d - (f - 1), yHi = anchorY + d;
+ int xCount = xHi - xLo + 1, yCount = yHi - yLo + 1;
+ int outerCount = xOuter ? xCount : yCount;
+ int innerCount = xOuter ? yCount : xCount;
+ for (int o = 0; o < outerCount; o++)
{
- for (int x = anchorX - d - (f - 1); x <= anchorX + d; x++)
+ for (int n = 0; n < innerCount; n++)
{
+ int xi = xOuter ? o : n;
+ int yi = xOuter ? n : o;
+ int x = xDesc ? xHi - xi : xLo + xi;
+ int y = yDesc ? yHi - yi : yLo + yi;
if (ChebyshevToFootprint(anchorX, anchorY, x, y) != d) continue;
if (_construction.ValidatePlacement(_aiPlayerId, buildingDefId, x, y)
== CommandResultCode.Applied)
diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs
index e687ec21..8b3e9288 100644
--- a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs
+++ b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs
@@ -142,6 +142,16 @@ public sealed class MatchBootstrap : MonoBehaviour
/// Canonical scenario seed (DeterminismOptions.Seed). Required for InitialStateHash parity: the PRNG words are hashed into the kernel state block.
public const ulong CanonicalSeed = 0xDE7E000000010271UL;
+ ///
+ /// When true, every LOCAL match (start and restart) draws a fresh seed,
+ /// so the skirmish AI's per-match personality differs from game to
+ /// game. Off by default: the canonical seed stays the default for
+ /// tests and harnesses (the canonical opening-position hash depends on
+ /// it); the main menu switches this on. Network matches take their
+ /// seed from the relay offer and ignore it.
+ ///
+ public bool FreshSeedPerLocalMatch { get; set; }
+
/// One Aetherium field of the canonical map (16.7, C1).
private struct FieldLayout
{
@@ -499,6 +509,7 @@ public void StartGrayboxMatch()
string relayHost = Environment.GetEnvironmentVariable("NOVA_RELAY_HOST");
if (string.IsNullOrWhiteSpace(relayHost))
{
+ ApplyLocalMatchSeed(local);
StartGrayboxMatch(local);
return;
}
@@ -805,12 +816,53 @@ private void BuildOpening(MatchConfig config)
SetupSlot(EnemyLayout);
}
+ ///
+ /// Chooses the seed of a LOCAL match. NOVA_MATCH_SEED (hex u64,
+ /// optional 0x) pins it — the hook for reproducing a reported match —
+ /// otherwise a fresh non-zero seed is drawn when
+ /// is on. The seed is the only
+ /// input of the AI personality, so a logged seed reproduces the match.
+ ///
+ private void ApplyLocalMatchSeed(MatchConfig config)
+ {
+ if (config.IsNetworkMatch) return;
+
+ string pinned = Environment.GetEnvironmentVariable("NOVA_MATCH_SEED");
+ if (!string.IsNullOrWhiteSpace(pinned))
+ {
+ string hex = pinned.Trim();
+ if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex.Substring(2);
+ if (ulong.TryParse(hex, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out ulong parsed))
+ {
+ config.Seed = parsed;
+ Debug.Log($"[MatchBootstrap] Match seed pinned by NOVA_MATCH_SEED: 0x{parsed:X16}.");
+ return;
+ }
+ }
+
+ if (!FreshSeedPerLocalMatch) return;
+
+ var bytes = new byte[8];
+ ulong fresh = 0;
+ using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create())
+ {
+ while (fresh == 0)
+ {
+ rng.GetBytes(bytes);
+ fresh = BitConverter.ToUInt64(bytes, 0);
+ }
+ }
+ config.Seed = fresh;
+ }
+
private void LogLocalMatchStarted()
{
+ string personality = Runner.SkirmishAi != null ? $", AI personality {Runner.AiPersonality}" : string.Empty;
Debug.Log(
$"[MatchBootstrap] Graybox match started (seed 0x{_seed:X16}, {_mapWidth}x{_mapHeight}, " +
$"capacity {_entityCapacity}, definition stats {UseDefinitionStats}, " +
- $"start credits {Runner.Economy.GetPlayerEconomy(ConfiguredLocalSlot).AetheriumCredits} AE).");
+ $"start credits {Runner.Economy.GetPlayerEconomy(ConfiguredLocalSlot).AetheriumCredits} AE" +
+ $"{personality}).");
}
private void SubmitNetworkProof(MatchConfig config)
@@ -865,6 +917,7 @@ public bool RestartMatch()
: MatchConfig.LocalVsAi(
_seed, _mapWidth, _mapHeight, _entityCapacity,
Nova.Simulation.Economy.EconomySystem.CanonicalMatchStartingCreditsAE);
+ ApplyLocalMatchSeed(restartConfig);
if (Runner != null && Runner.IsRunning)
{
Runner.PauseMatch();
diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs
index 41da921f..45c3c77a 100644
--- a/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs
+++ b/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs
@@ -1,6 +1,7 @@
using System;
using UnityEngine;
using Nova.AI;
+using Nova.AI.Data;
using Nova.Core;
using Nova.Simulation;
using Nova.Simulation.Combat;
@@ -114,6 +115,9 @@ public class MatchRunner : MonoBehaviour
/// Validated, ownership-safe configuration of the current match.
public MatchConfig Config => _config?.ValidateAndClone();
+ /// Seed of the running match; cheap to read every frame, unlike which clones.
+ public ulong Seed => _seed;
+
/// The bound relay client, or null for the canonical local match.
public RelayMatchClient RelayClient => _relayClient;
@@ -154,6 +158,12 @@ public class MatchRunner : MonoBehaviour
/// The registered MS-1 skirmish opponent (slot 1, Legion); null when disabled.
public SkirmishAiSystem SkirmishAi { get; private set; }
+ ///
+ /// The flavour the skirmish AI plays this match, derived from the seed
+ /// and its slot; default (neutral) when there is no AI.
+ ///
+ public AiPersonality AiPersonality { get; private set; }
+
public bool IsRunning => Kernel != null && Kernel.IsRunning;
///
@@ -199,6 +209,7 @@ public void InitializeMatch(MatchConfig config)
AiSession = null;
AiIngress = null;
SkirmishAi = null;
+ AiPersonality = default;
// The two lab switches are process-wide statics and a match is not.
// Without this, a reveal set while watching the AI in a skirmish
@@ -247,14 +258,21 @@ public void InitializeMatch(MatchConfig config)
AiSession = new MatchSession(aiSlot, _config.ActiveSlots, _config.InputDelayTicks);
AiIngress = new CommandIngress(AiSession);
_aiTransport = new AiPeerCommandTransport(AiIngress, Ingress);
- SkirmishAi = new SkirmishAiSystem(
- aiPlayerId: aiSlot,
- new AiFactionProfile(_config.FactionPerSlot[aiSlot].ToString(),
+ AiFactionProfile baseProfile = new AiFactionProfile(_config.FactionPerSlot[aiSlot].ToString(),
targetPowerMargin: 0, // power plant only when the margin would go negative
targetArmySize: 12,
attackSquadThreshold: 6,
- targetHarvesterCount: 2),
- AiIngress, Entities, Economy, Construction, Production, FogOfWar, Victory);
+ targetHarvesterCount: 2);
+ // The personality is a pure function of (match seed, AI slot):
+ // a logged seed reproduces the opponent, and the AI itself
+ // stays stateless — nothing to snapshot, nothing to restore.
+ AiPersonality personality = AiPersonality.Derive(_config.Seed, aiSlot);
+ AiPersonality = personality;
+ SkirmishAi = new SkirmishAiSystem(
+ aiPlayerId: aiSlot,
+ baseProfile.WithPersonality(personality),
+ AiIngress, Entities, Economy, Construction, Production, FogOfWar, Victory,
+ personality: personality);
}
// Canonical tick order (SimulationCore.md section 2): economy
diff --git a/Assets/_Project/Scripts/Gameplay/Nova.Gameplay.asmdef b/Assets/_Project/Scripts/Gameplay/Nova.Gameplay.asmdef
index d3d9b348..a130ed8d 100644
--- a/Assets/_Project/Scripts/Gameplay/Nova.Gameplay.asmdef
+++ b/Assets/_Project/Scripts/Gameplay/Nova.Gameplay.asmdef
@@ -5,6 +5,7 @@
"Nova.Core",
"Nova.Simulation",
"Nova.AI",
+ "Nova.AI.Data",
"Nova.Data",
"Nova.Networking"
],
diff --git a/Assets/_Project/Scripts/Presentation/UI/DebugHud.cs b/Assets/_Project/Scripts/Presentation/UI/DebugHud.cs
index 4ed04534..01893019 100644
--- a/Assets/_Project/Scripts/Presentation/UI/DebugHud.cs
+++ b/Assets/_Project/Scripts/Presentation/UI/DebugHud.cs
@@ -353,6 +353,9 @@ private void DrawMatchLine()
// report and an entry in the behaviour journal can be tied to each
// other without asking anybody what was built that day.
GUILayout.Label($"AI behaviour {AiBehaviorId.Value}", _labelStyle);
+ // The personality is derived from the seed alone, so seed plus this
+ // line is enough to replay "the match where the AI rushed".
+ GUILayout.Label($"AI personality {_runner.AiPersonality} (seed 0x{_runner.Seed:X16})", _labelStyle);
// WHICH SIMULATION is this? The definition table's hash plus the
// five schema versions — the same values the match fingerprint
diff --git a/Assets/_Project/Scripts/Presentation/UI/MainMenuController.cs b/Assets/_Project/Scripts/Presentation/UI/MainMenuController.cs
index f290a695..5423d086 100644
--- a/Assets/_Project/Scripts/Presentation/UI/MainMenuController.cs
+++ b/Assets/_Project/Scripts/Presentation/UI/MainMenuController.cs
@@ -716,6 +716,8 @@ private void StartMatch()
// belongs to the fresh match rather than to the empty scene.
// A match that already ran is restarted cleanly instead of being
// stacked (RestartMatch rebuilds the kernel and every system).
+ // The player-facing start plays a fresh match; tests keep the canonical seed.
+ _bootstrap.FreshSeedPerLocalMatch = true;
if (_bootstrap.IsMatchReady)
{
_bootstrap.RestartMatch();
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1433b138..3af38f47 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -73,6 +73,27 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
spielerisch abgenommen und kein Meilenstein-Nachweis
### Hinzugefügt
+- **Die KI spielt nicht mehr jede Partie gleich (D-114, Stufe A).** Bisher lief
+ jede lokale Partie identisch: Die KI würfelt bewusst nie, und das Menü
+ startete immer mit demselben Seed. Jetzt zieht „Neues Spiel“ und „Neue Runde“
+ je Partie einen frischen Seed.
+ - Aus Seed und Slot leitet die KI eine **Persönlichkeit** ab, als reine
+ Funktion ohne Zustand und ohne Zufallszüge während der Partie.
+ - **Eröffnung:** ausgewogen; Ansturm (Kaserne vor Raffinerie, kleinere und
+ frühere Welle); Ökonom (dritter Sammler, größere Armee); Igel (ein bis zwei
+ Abwehrplattformen Richtung Front).
+ - **Sammelpunkt:** um zehn Felder zur Seite versetzt oder mittig.
+ - **Bauplätze:** eine von acht Suchrichtungen, damit die Basis jedes Mal
+ anders aussieht.
+ - **Profilwerte:** kleine Streuung in festen Bändern.
+ - Das Debug-HUD (F3) und das Startlog zeigen Persönlichkeit und Seed;
+ `NOVA_MATCH_SEED=` stellt eine gemeldete Partie nach.
+ - Die neutrale Persönlichkeit spielt exakt wie bisher. Der gepinnte Ausgang
+ der kanonischen KI-Partie bleibt stehen, `AiBehaviorId` steigt auf r9.
+ Netzpartien sind unberührt.
+ - **Bewusst klein:** Solange die KI nur Infanterie und drei Gebäude kennt,
+ variieren Bauordnung, Basisbild, Sammelpunkt und Angriffszeitpunkt, mehr
+ nicht. Panzer, Konter-Bau und Tech folgen als Stufe B.
- **Fahrzeugfabrik und Kaserne reparieren jetzt passiv, was sie bauen können (#55).**
Der Wunsch stammt aus dem Betatest vom 09.08.2026 und wurde am 31.08.2026
erneut vermisst: beschädigte Panzerung war bis hierher faktisch dauerhaft —
diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md
index 6a31b3ce..c00a15ca 100644
--- a/docs/production/DecisionLog.md
+++ b/docs/production/DecisionLog.md
@@ -1,6 +1,6 @@
# Decision Log
-**Version:** 1.41.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21
+**Version:** 1.46.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21
## Zweck
@@ -3583,6 +3583,85 @@ sie braucht deshalb ein eigenes, mit dem Einheitenstrang abgestimmtes
Merge-Fenster. Verteidigungsbalance an den Zufahrten und KI-Verhalten in der
Mitte sind ausdrücklich nicht Teil dieser Entscheidung.
+### D-114 | verbindlich | KI-Strang (eine Persönlichkeit je Partie statt Zufallszügen im Spiel)
+
+**Status:** Inhaberentscheidung vom 2026-10-17. Die Nummern D-110 bis D-113 sind
+vom Asset-Pass auf `feat/generals-look-and-feel` belegt.
+
+**Kontext:** Jede lokale Partie gegen die KI lief identisch ab. Am Code geprüft
+(`main` @ `85c912b`), stapeln sich drei Ursachen:
+
+1. Die Skirmish-KI ist bewusst zustandslos und ohne Zufall. Jede Entscheidung
+ ist eine reine Funktion aus Tick und committed Zustand.
+2. Die lokale Partie startet immer mit `MatchBootstrap.CanonicalSeed`.
+3. Das Repertoire ist klein: eine Bauordnung, nur Basisinfanterie, drei von
+ neun Gebäuderollen.
+
+Der KI-Strang ist seit Arns letzter Aktivität (2026-08-21) unbesetzt; der
+Inhaber übernimmt ihn. Als Vorbild dient C&C Generals: der offene EA-Quellcode
+(GPL-3), aus dem nur Konzepte übernommen werden, kein Code. Generals wählt unter
+gleich priorisierten Teams per Spielzufall aus dem Simulations-Seed und zieht
+das Fähigkeiten-Set des Computergegners je Partie neu.
+
+**Alternativen:**
+
+1. Die KI zieht während der Partie aus dem Simulations-PRNG (`SimRandom`). Das
+ greift in den Zufallsstrom aller Systeme ein. Außerdem hinge jede
+ KI-Entscheidung vom Ziehzeitpunkt ab, und Save/Restore bräuchte KI-Zustand.
+2. Die KI bekommt einen eigenen PRNG-Zustand im Sidecar
+ ([AIArchitecture.md](../tech/AIArchitecture.md) §4). Das braucht einen
+ Snapshot-Block und damit eine eigene Grundsatzentscheidung. Für Abwechslung
+ *zwischen* Partien ist das nicht nötig.
+3. Eine **Persönlichkeit als reine Funktion aus Match-Seed und KI-Slot**, beim
+ Aufbau festgelegt, dazu ein frischer Seed je lokaler Partie aus dem Menü.
+
+**Entscheidung:** Alternative 3.
+
+- `AiPersonality.Derive(seed, slot)` hasht einen Domänen-Tag, den Seed und den
+ Slot (XxHash64). Daraus wählt die Funktion:
+ - eine von vier Eröffnungen:
+ - **Ausgewogen** (heutiges Verhalten mit leichter Streuung);
+ - **Ansturm**: Kaserne vor Raffinerie, kleinere Armee, die früher losschlägt;
+ - **Ökonom**: dritter Sammler und größere Armee;
+ - **Igel**: ein bis zwei Abwehrplattformen zwischen HQ und Sammelpunkt;
+ - einen seitlich versetzten Sammelpunkt (−10, 0 oder +10 Felder);
+ - eine von acht Suchrichtungen für Bauplätze;
+ - kleine Streuungen der Profilwerte in festen Bändern.
+- Die neutrale Persönlichkeit (`default`) spielt exakt wie r8. Der gepinnte
+ Ausgang der kanonischen KI-Partie bewegt sich nicht.
+- Eine Persönlichkeit schaltet keine Regel ein, die das Grundprofil
+ abgeschaltet hat.
+- `MatchBootstrap.FreshSeedPerLocalMatch` ist standardmäßig aus, damit Tests und
+ Harnesses beim kanonischen Seed bleiben. Das Hauptmenü schaltet es ein, und
+ „Neue Runde“ zieht dann ebenfalls neu.
+- `NOVA_MATCH_SEED` pinnt den Seed, um eine gemeldete Partie nachzustellen.
+- Netzpartien sind unberührt; ihr Seed kommt vom Relay.
+- Debug-HUD (F3) und Startlog zeigen Persönlichkeit und Seed.
+- `AiBehaviorId` steigt auf r9. Der Profil-Hash bleibt, weil sich
+ `Ms1Canonical` nicht ändert.
+
+**Begründung:** Für Abwechslung zwischen Partien braucht es keine Zufallszüge
+während der Partie. Als reine Funktion bleibt die KI zustandslos und
+Save/Restore-identisch, und der Seed allein reproduziert den Gegner.
+Snapshot-Format, Determinismus-Baselines und Match-Fingerprint werden nicht
+berührt.
+
+**Konsequenzen:**
+
+- **Die Vielfalt ist bewusst klein.** Mit Infanterie und drei Gebäuderollen
+ variieren nur Bauordnung, Basisbild, Sammelpunkt und Angriffszeitpunkt.
+ Headless gegen einen passiven Gegner entscheiden die Eröffnungen die Partie
+ zwischen rund 2.000 und 3.000 Ticks; kanonisch sind es 2.726.
+- **Stufe B ist der eigentliche Hebel:** Fahrzeugfabrik, Panzer, Konter-Bau
+ nach sichtbarer Gegnerzusammensetzung, Labor und Tier 2. Sie kommt in eigenen
+ PRs.
+- **Stufe C bleibt eine eigene Entscheidung:** Lernen innerhalb der Partie
+ (Generals: Teampriorität steigt nach Erfolg und sinkt nach Misserfolg)
+ braucht KI-Gedächtnis im Spielstand.
+- **Der kanonische Seed spielt nicht mehr neutral.** Er leitet einen
+ Ansturm-Gegner ab. Wer ohne Menü startet (AutoStart, Harness), spielt diesen,
+ nicht r8.
+
## Offene Punkte
- Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden.
@@ -3663,6 +3742,7 @@ Mitte sind ausdrücklich nicht Teil dieser Entscheidung.
| Version | Datum | Änderung | Autor |
|---|---|---|---|
+| 1.46.0 | 2026-10-17 | D-114 aufgenommen: Die Skirmish-KI bekommt je Partie eine Persönlichkeit als reine Funktion aus Match-Seed und Slot (Eröffnung, Sammelpunkt-Flanke, Bauplatz-Suchrichtung, Profilstreuung); das Hauptmenü zieht je lokaler Partie einen frischen Seed. Die Versionen 1.42.0 bis 1.45.0 und D-110 bis D-113 trägt der Asset-Pass auf `feat/generals-look-and-feel` | Dennis Westermann / Orchestrator |
| 1.41.0 | 2026-08-18 | **D-108 in der Prämisse berichtigt und in der Sache neu gefasst.** Die Erstfassung behauptete, der Code lasse bereits jedes Gebäude als Bauanker gelten, und wollte das nur aussprechen — tatsächlich prüft `IsInsideBuildInfluence` seit D-104 auf HQ, Lager und Kraftwerk. Der ausführende Agent hat den Widerspruch beim Umsetzen von Paket 21.1 gefunden und angehalten. Der Inhaber hat daraufhin in Kenntnis der Lage neu entschieden: die Ankerliste wird bewusst geöffnet, **jedes eigene fertiggestellte Gebäude wird Anker**. Damit ist D-108 eine Verhaltensänderung (`RulesHash64`, Baselines, kanonischer KI-Pin), keine Festschreibung. `MinimumBuildingDistanceCells` bleibt bei 2 — die Messung aus 21.1 (15/23/23) weist die gemeldete Enge nicht als Abstandsproblem aus | Dennis Westermann / Orchestrator |
| 1.40.0 | 2026-08-17 | D-108 und D-109 aufgenommen (Sprint 21, aus Testbericht T-01): Territorium wächst **kriechend an jedem eigenen Bauanker** — die bestehende Regel wird festgeschrieben statt geändert, und die gemeldete Enge wird an `MinimumBuildingDistanceCells` gemessen, bevor ein Wert fällt. Die Kartenmitte wird ein Gebiet mit schmalen Zufahrten, mit der verbindlichen Auflage, dass Begehbarkeit und Optik aus **einer** Struktur stammen — heute streut `GlutrinneBlockoutView` begehbare Deko-Felsen, während niemand ausser `ConstructionSystem` in das `CostField` schreibt | Dennis Westermann / Orchestrator |
| 1.39.0 | 2026-08-10 | D-107 aufgenommen: Die bestehende Glutrinne-Layoutachse spiegelt Punkte um 124 und 3×3-Footprint-Ursprünge abgeleitet um 122; der zweite HQ-Ursprung wird von `(120,120)` auf `(118,118)` korrigiert | Agent (unter Delegation) / Dennis Westermann |
diff --git a/tools/Nova.SimRunner.Tests/AiPersonalityTests.cs b/tools/Nova.SimRunner.Tests/AiPersonalityTests.cs
new file mode 100644
index 00000000..fbf9df3f
--- /dev/null
+++ b/tools/Nova.SimRunner.Tests/AiPersonalityTests.cs
@@ -0,0 +1,272 @@
+using System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using Nova.AI;
+using Nova.AI.Data;
+using Nova.Simulation;
+using Nova.Simulation.Construction;
+using Nova.Simulation.Definitions;
+using Nova.Simulation.State;
+
+namespace Nova.SimRunner.Tests
+{
+ ///
+ /// The per-match AI personality (behaviour r9): the derivation is a pure
+ /// function of (seed, slot), the neutral personality changes nothing, and
+ /// every personality still plays a match to its end. The neutral half of
+ /// the claim is pinned by CanonicalAiOutcomeTests, which keeps
+ /// playing default and must stay green unchanged.
+ ///
+ [TestFixture]
+ public sealed class AiPersonalityTests
+ {
+ private const byte AiSlot = 1;
+ private const byte HumanSlot = 0;
+
+ // ----------------------------------------------------------------
+ // Derive and Apply — pure data
+ // ----------------------------------------------------------------
+
+ [Test]
+ public void Derive_IsPure_AndCoversEveryBandOver512Seeds()
+ {
+ var openings = new int[4];
+ var flanks = new HashSet();
+ var layouts = new HashSet();
+ const int count = 512;
+ for (ulong seed = 0; seed < count; seed++)
+ {
+ AiPersonality p = AiPersonality.Derive(seed * 0x9E3779B97F4A7C15UL + 1, AiSlot);
+ Assert.That(AiPersonality.Derive(seed * 0x9E3779B97F4A7C15UL + 1, AiSlot), Is.EqualTo(p));
+ openings[(int)p.Opening]++;
+ flanks.Add(p.FlankOffsetCells);
+ layouts.Add(p.LayoutVariant);
+
+ Assert.That(p.FlankOffsetCells, Is.AnyOf(-10, 0, 10));
+ Assert.That(p.LayoutVariant, Is.InRange((byte)0, (byte)7));
+ // Resulting values against the MatchRunner base (12 / 6 / 2 / 60 / 10).
+ int army = 12 + p.ArmySizeDelta;
+ int retreat = 60 + p.RetreatPercentDelta;
+ switch (p.Opening)
+ {
+ case AiOpening.Balanced:
+ Assert.That(army, Is.InRange(11, 13));
+ Assert.That(retreat, Is.InRange(55, 65));
+ Assert.That(p.DefensePlatforms, Is.Zero);
+ break;
+ case AiOpening.Rush:
+ Assert.That(army, Is.InRange(7, 9));
+ Assert.That(6 + p.SquadThresholdDelta, Is.EqualTo(4));
+ Assert.That(retreat, Is.InRange(40, 50));
+ break;
+ case AiOpening.Economy:
+ Assert.That(army, Is.InRange(15, 17));
+ Assert.That(2 + p.HarvesterDelta, Is.EqualTo(3));
+ Assert.That(retreat, Is.InRange(60, 70));
+ break;
+ case AiOpening.Turtle:
+ Assert.That(army, Is.InRange(13, 15));
+ Assert.That(retreat, Is.InRange(60, 70));
+ Assert.That(10 + p.DefendHomeCellsDelta, Is.EqualTo(14));
+ Assert.That(p.DefensePlatforms, Is.InRange(1, 2));
+ break;
+ }
+ }
+
+ for (int o = 0; o < 4; o++)
+ {
+ double share = openings[o] / (double)count;
+ Assert.That(share, Is.InRange(0.15, 0.35), $"opening {(AiOpening)o} share");
+ }
+ Assert.That(flanks, Is.EquivalentTo(new[] { -10, 0, 10 }));
+ Assert.That(layouts.Count, Is.EqualTo(8));
+ }
+
+ [Test]
+ public void Derive_DependsOnSlotToo()
+ {
+ int differing = 0;
+ for (ulong seed = 1; seed <= 64; seed++)
+ {
+ if (!AiPersonality.Derive(seed, 0).Equals(AiPersonality.Derive(seed, 1))) differing++;
+ }
+ Assert.That(differing, Is.GreaterThan(32));
+ }
+
+ [Test]
+ public void Neutral_ApplyReturnsTheBaseProfileUnchanged()
+ {
+ Assert.That(AiPersonality.Neutral, Is.EqualTo(default(AiPersonality)));
+ Assert.That(default(AiPersonality).IsNeutral, Is.True);
+ AiProfile shipped = AiProfiles.Ms1Canonical;
+ Assert.That(AiPersonality.Neutral.Apply(shipped), Is.EqualTo(shipped));
+ Assert.That(AiBehaviorId.ComputeProfileHash(AiPersonality.Neutral.Apply(shipped)),
+ Is.EqualTo(AiBehaviorId.ProfileHash));
+ }
+
+ [Test]
+ public void Apply_NeverSwitchesOnARuleTheBaseProfileHasOff()
+ {
+ AiProfile shipped = AiProfiles.Ms1Canonical;
+ AiProfile off = new AiProfile(
+ "off", shipped.DecisionTickInterval, shipped.PlacementSearchRadius, 0, 2,
+ shipped.HarvesterQueueBatch, 12, 6, shipped.InfantryQueueBatch,
+ shipped.TargetDamageWeight, shipped.TargetThreatWeight, shipped.TargetFinishWeight,
+ shipped.TargetDistanceWeight, shipped.WaveSize, shipped.StagingDistanceCells,
+ shipped.StagingToleranceCells, retreatHealthPercent: 0, shipped.RetreatDangerCells,
+ shipped.WaveStrengthPoints, defendHomeCells: 0);
+ foreach (AiOpening opening in new[] { AiOpening.Balanced, AiOpening.Rush, AiOpening.Economy, AiOpening.Turtle })
+ {
+ AiProfile applied = AiPersonality.Create(opening, 0, 0, 1, 1, 1).Apply(off);
+ Assert.That(applied.RetreatHealthPercent, Is.Zero, opening.ToString());
+ Assert.That(applied.DefendHomeCells, Is.Zero, opening.ToString());
+ }
+ }
+
+ [Test]
+ public void Apply_RenamesAndClamps()
+ {
+ AiProfile applied = AiPersonality.Create(AiOpening.Rush, 0, 0, -1, -1).Apply(AiProfiles.Ms1Canonical);
+ Assert.That(applied.ProfileId, Is.EqualTo(AiProfiles.Ms1Canonical.ProfileId + "+rush"));
+ Assert.That(applied.AttackSquadThreshold, Is.GreaterThanOrEqualTo(1));
+ Assert.That(applied.TargetArmySize, Is.GreaterThanOrEqualTo(applied.AttackSquadThreshold));
+ Assert.That(applied.RetreatHealthPercent, Is.InRange(0, 95));
+ }
+
+ // ----------------------------------------------------------------
+ // Whole matches
+ // ----------------------------------------------------------------
+
+ private sealed class Outcome
+ {
+ public uint DecidedTick;
+ public ulong EndState;
+ public int BarracksTick, RefineryTick, PlatformCount;
+ }
+
+ private static Outcome Play(ulong seed, AiPersonality personality, FactionId aiFaction)
+ {
+ SkirmishAiTests.AiHost host = SkirmishAiTests.BuildMatch(
+ seed, personality: personality, aiFaction: aiFaction);
+ var outcome = new Outcome();
+ for (int i = 0; i < SkirmishAiTests.EndToEndBudgetTicks && !host.Victory.IsDecided; i++)
+ {
+ host.Step();
+ uint tick = host.Kernel.CurrentTick.Value;
+ if (outcome.BarracksTick == 0 && CountCompleted(host, UnitRole.Barracks) > 0) outcome.BarracksTick = (int)tick;
+ if (outcome.RefineryTick == 0 && CountCompleted(host, UnitRole.Refinery) > 0) outcome.RefineryTick = (int)tick;
+ }
+ outcome.PlatformCount = CountCompleted(host, UnitRole.DefensePlatform);
+ outcome.DecidedTick = host.Victory.DecidedTick;
+ outcome.EndState = host.Kernel.CalculateStateHash();
+ return outcome;
+ }
+
+ /// Completed own buildings of a role — an unfinished site carries its definition role too, so sites are excluded.
+ private static int CountCompleted(SkirmishAiTests.AiHost host, UnitRole role)
+ {
+ int count = 0;
+ UnitState[] units = host.Entities.RawUnits;
+ for (int i = 0; i < host.Entities.Capacity; i++)
+ {
+ ref readonly UnitState u = ref units[i];
+ if (!u.IsActive || u.PlayerId != AiSlot || u.Role != role) continue;
+ uint raw = UnitCommandStateView.ToRawEntityId(u.Id);
+ if (host.Construction.TryGetSite(raw, out _, out _, out _)) continue;
+ count++;
+ }
+ return count;
+ }
+
+ [Test]
+ public void EveryOpening_FlankAndFaction_FinishesTheMatch()
+ {
+ var ticks = new Dictionary>();
+ foreach (FactionId faction in new[] { FactionId.Legion, FactionId.Alliance })
+ {
+ foreach (AiOpening opening in new[] { AiOpening.Balanced, AiOpening.Rush, AiOpening.Economy, AiOpening.Turtle })
+ {
+ foreach (int flank in new[] { -10, 0, 10 })
+ {
+ // Layout 5 (x descending, x outer) exercises the non-default scan.
+ AiPersonality p = AiPersonality.Create(opening, flank, layoutVariant: 5, extraDefensePlatform: 1);
+ Outcome o = Play(SkirmishAiTests.Seed, p, faction);
+ string label = $"{faction} {opening} flank {flank}";
+ Assert.That(o.DecidedTick, Is.Not.Zero, label + " did not decide the match");
+ TestContext.Progress.WriteLine($"[personality] {label}: decided {o.DecidedTick}, " +
+ $"barracks {o.BarracksTick}, refinery {o.RefineryTick}, platforms {o.PlatformCount}");
+ if (opening == AiOpening.Turtle)
+ Assert.That(o.PlatformCount, Is.GreaterThanOrEqualTo(1), label + " built no defence platform");
+ if (opening == AiOpening.Rush)
+ Assert.That(o.BarracksTick, Is.GreaterThan(0).And.LessThan(o.RefineryTick), label + " Barracks before Refinery");
+ }
+ }
+ }
+ }
+
+ [Test]
+ public void SameSeedSamePersonality_SameMatch()
+ {
+ const ulong seed = 0xDE7E000000010271UL;
+ AiPersonality p = AiPersonality.Derive(seed, AiSlot);
+ Outcome a = Play(seed, p, FactionId.Legion);
+ Outcome b = Play(seed, p, FactionId.Legion);
+ Assert.That(a.DecidedTick, Is.Not.Zero);
+ Assert.That(b.DecidedTick, Is.EqualTo(a.DecidedTick));
+ Assert.That(b.EndState, Is.EqualTo(a.EndState));
+ }
+
+ [Test]
+ public void SeedsWithDifferentOpenings_PlayDifferentMatches()
+ {
+ var seedByOpening = new Dictionary();
+ for (ulong seed = 1; seed < 200 && seedByOpening.Count < 4; seed++)
+ {
+ AiOpening o = AiPersonality.Derive(seed, AiSlot).Opening;
+ if (!seedByOpening.ContainsKey(o)) seedByOpening[o] = seed;
+ }
+ Assert.That(seedByOpening.Count, Is.EqualTo(4));
+
+ var signatures = new HashSet<(uint, ulong)>();
+ foreach (KeyValuePair pair in seedByOpening)
+ {
+ Outcome o = Play(pair.Value, AiPersonality.Derive(pair.Value, AiSlot), FactionId.Legion);
+ Assert.That(o.DecidedTick, Is.Not.Zero, $"seed {pair.Value} ({pair.Key})");
+ signatures.Add((o.DecidedTick, o.EndState));
+ }
+ Assert.That(signatures.Count, Is.GreaterThan(1), "four different openings produced identical matches");
+ }
+
+ // ----------------------------------------------------------------
+ // Flank cells on the real map
+ // ----------------------------------------------------------------
+
+ [TestCase((byte)1, 118, 117, 7)]
+ [TestCase((byte)0, 4, 7, 117)]
+ public void FlankedStagingCells_AreInsideTheGridAndWalkable(byte slot, int hqOrigin, int ownField, int enemyField)
+ {
+ _ = ownField;
+ const int distance = 12; // AiProfiles.Ms1Canonical.StagingDistanceCells
+ Assert.That(AiProfiles.Ms1Canonical.StagingDistanceCells, Is.EqualTo(distance));
+ foreach (int flank in new[] { -10, 10 })
+ {
+ // Mirror of SkirmishAiSystem.GetStagingCell (flank branch); the enemy start area is the far field.
+ int dx = enemyField - hqOrigin;
+ int dy = enemyField - hqOrigin;
+ int span = Math.Max(Math.Abs(dx), Math.Abs(dy));
+ Assert.That(span, Is.GreaterThan(distance));
+ int cx = Clamp(hqOrigin + dx * distance / span);
+ int cy = Clamp(hqOrigin + dy * distance / span);
+ int fx = Clamp(cx + (-dy * flank / span));
+ int fy = Clamp(cy + (dx * flank / span));
+ Assert.That(fx, Is.InRange(0, ConstructionSystem.GridSize - 1), $"slot {slot} flank {flank}");
+ Assert.That(fy, Is.InRange(0, ConstructionSystem.GridSize - 1), $"slot {slot} flank {flank}");
+ Assert.That(CanonicalTerrainMirror.IsImpassable(fx, fy), Is.False,
+ $"slot {slot} flank {flank}: cell ({fx},{fy}) is rock");
+ Assert.That(fx != cx || fy != cy, Is.True, "the flank must actually move the cell");
+ }
+ }
+
+ private static int Clamp(int c) => Math.Max(0, Math.Min(ConstructionSystem.GridSize - 1, c));
+ }
+}
diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs
index 309cd48c..01cfcf50 100644
--- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs
+++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs
@@ -176,8 +176,12 @@ private static AiHost BuildAiHost(
ulong seed,
AiProfile? profile = null,
IAiGoalObserver goalObserver = null,
- IAiGoalOverride goalOverride = null)
+ IAiGoalOverride goalOverride = null,
+ AiPersonality personality = default,
+ FactionId aiFaction = FactionId.Legion)
{
+ FactionId humanFaction = aiFaction == FactionId.Legion ? FactionId.Alliance : FactionId.Legion;
+ string aiFactionName = aiFaction.ToString();
// Mirror of MatchRunner.InitializeMatch(seed, ..., enableSkirmishAi: true).
var kernel = new SimulationKernel(new SimRandom(seed));
@@ -198,14 +202,16 @@ private static AiHost BuildAiHost(
var aiSession = new MatchSession(AiSlot, activeSlots: new byte[] { HumanSlot, AiSlot }, inputDelayTicks: 1);
var aiIngress = new CommandIngress(aiSession);
_ = new AiPeerCommandTransport(aiIngress, ingress);
+ AiFactionProfile baseProfile = profile.HasValue
+ ? new AiFactionProfile(aiFactionName, profile.Value)
+ : new AiFactionProfile(aiFactionName,
+ targetPowerMargin: 0, targetArmySize: 12, attackSquadThreshold: 6, targetHarvesterCount: 2);
+ // Applied the way MatchRunner does it: deltas into the profile, the rest by argument.
var ai = new SkirmishAiSystem(
AiSlot,
- profile.HasValue
- ? new AiFactionProfile("Legion", profile.Value)
- : new AiFactionProfile("Legion",
- targetPowerMargin: 0, targetArmySize: 12, attackSquadThreshold: 6, targetHarvesterCount: 2),
+ baseProfile.WithPersonality(personality),
aiIngress, entities, economy, construction, production, fogOfWar, victory,
- goalObserver, goalOverride);
+ goalObserver, goalOverride, personality);
kernel.RegisterSystem(economy);
kernel.RegisterSystem(construction);
@@ -221,8 +227,8 @@ private static AiHost BuildAiHost(
// Faction assignment BEFORE Kernel.Start() (the SetSlotFaction
// guard): slot 0 Alliance, slot 1 Legion — the canonical match.
- economy.SetSlotFaction(HumanSlot, FactionId.Alliance);
- economy.SetSlotFaction(AiSlot, FactionId.Legion);
+ economy.SetSlotFaction(HumanSlot, humanFaction);
+ economy.SetSlotFaction(AiSlot, aiFaction);
kernel.Start();
return new AiHost
@@ -287,9 +293,11 @@ internal static AiHost BuildMatch(
ulong seed,
AiProfile? profile = null,
IAiGoalObserver goalObserver = null,
- IAiGoalOverride goalOverride = null)
+ IAiGoalOverride goalOverride = null,
+ AiPersonality personality = default,
+ FactionId aiFaction = FactionId.Legion)
{
- AiHost host = BuildAiHost(seed, profile, goalObserver, goalOverride);
+ AiHost host = BuildAiHost(seed, profile, goalObserver, goalOverride, personality, aiFaction);
ApplyOpeningPosition(host);
return host;
}
@@ -554,7 +562,7 @@ public void AiBehaviorId_TracksWhichAiThisIs()
// makes it measurable one-sided (defendHomeCells, 0 = off, M001).
// A revision bump with an unmoved profile hash would have meant a
// rule nobody can switch off.
- Assert.That(AiBehaviorId.Value, Is.EqualTo("r8.1E6E7AE3"),
+ Assert.That(AiBehaviorId.Value, Is.EqualTo("r9.1E6E7AE3"),
"the AI identifier changed — bump the revision and write the journal entry");
}