From 6e00c02788e593e0745f9f15fbf4ac605080ae57 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 15:48:57 +0200 Subject: [PATCH] feat(ai): give the skirmish AI a personality per match (D-114) Every local match against the AI played out identically: the AI is stateless with no PRNG by design, and the menu always started the canonical seed. Stage A varies the opponent between matches without drawing randomness during play. AiPersonality.Derive(seed, slot) is a pure function (XxHash64) that picks an opening (Balanced, Rush, Economy, Turtle), a flanked staging cell, one of eight placement-scan orientations and banded profile deltas. It is fixed at construction like the profile, so the AI stays stateless and save/restore-identical, and a logged seed reproduces the opponent. The main menu turns on a fresh seed per local match; tests and harnesses keep the canonical seed, NOVA_MATCH_SEED pins one, network matches keep the relay seed. The neutral personality reproduces r8 tick for tick, so the canonical AI outcome pin is unchanged; AiBehaviorId moves to r9 with the same profile hash. Co-Authored-By: Claude Opus 5.5 --- Assets/Tests/EditMode/AI/Nova.AI.Tests.asmdef | 3 +- .../_Project/Scripts/AI.Data/AiBehaviorId.cs | 16 +- .../_Project/Scripts/AI.Data/AiPersonality.cs | 240 ++++++++++++++++ .../Scripts/AI.Data/AiPersonality.cs.meta | 2 + .../_Project/Scripts/AI/AiFactionProfile.cs | 7 + .../_Project/Scripts/AI/SkirmishAiSystem.cs | 117 +++++++- .../Scripts/Gameplay/Match/MatchBootstrap.cs | 55 +++- .../Scripts/Gameplay/Match/MatchRunner.cs | 28 +- .../Scripts/Gameplay/Nova.Gameplay.asmdef | 1 + .../Scripts/Presentation/UI/DebugHud.cs | 3 + .../Presentation/UI/MainMenuController.cs | 2 + CHANGELOG.md | 21 ++ docs/production/DecisionLog.md | 82 +++++- .../AiPersonalityTests.cs | 272 ++++++++++++++++++ tools/Nova.SimRunner.Tests/SkirmishAiTests.cs | 30 +- 15 files changed, 848 insertions(+), 31 deletions(-) create mode 100644 Assets/_Project/Scripts/AI.Data/AiPersonality.cs create mode 100644 Assets/_Project/Scripts/AI.Data/AiPersonality.cs.meta create mode 100644 tools/Nova.SimRunner.Tests/AiPersonalityTests.cs 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"); }