diff --git a/AGENTS.md b/AGENTS.md index 4b2a620..af9de38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,20 @@ pwsh scripts/check_unity_build.ps1 This automatically rebuilds Core.dll, copies it to Unity Plugins, and compiles Unity scripts. Exits 0 if all pass. +## 7. Run Targeted Tests, Not Full Suite + +**Never run all tests unless explicitly asked.** Always use `--filter` to run only the tests relevant to what you changed. + +```bash +# Run a single test: +pwsh -Command "dotnet test test/unit/CounterDrone.Core.Tests/ --filter 'FullyQualifiedName~TestName'" + +# Run a test class: +pwsh -Command "dotnet test test/unit/CounterDrone.Core.Tests/ --filter 'FullyQualifiedName~DefensePlannerTests'" +``` + +Running all 125+ tests wastes ~30 seconds every time and buries the failures you actually care about. + --- **These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs b/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs index 786757b..0d0a26b 100644 --- a/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs +++ b/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs @@ -5,7 +5,7 @@ using CounterDrone.Core.Models; namespace CounterDrone.Core.Algorithms { - /// 默认防御规划器 — 五步流水线 + /// 默认防御规划器 — 五步流水线,全部使用真实物理计算 public class DefaultDefensePlanner : IDefensePlanner { private readonly List _ammoCatalog; @@ -15,7 +15,6 @@ namespace CounterDrone.Core.Algorithms _ammoCatalog = ammoCatalog ?? new List(); } - /// 弹药匹配表:PowerType → 最佳 AerosolType private static readonly Dictionary MatchTable = new() { { PowerType.Electric, AerosolType.InertGas }, @@ -23,7 +22,6 @@ namespace CounterDrone.Core.Algorithms { PowerType.Jet, AerosolType.ActiveMaterial }, }; - /// 威胁类型系数(越大越危险) private static readonly Dictionary TypeCoefficient = new() { { TargetType.HighSpeed, 4f }, @@ -64,7 +62,7 @@ namespace CounterDrone.Core.Algorithms // Step 2-4: 贪心分配求解 result.Best = Solve(sorted, fireUnits, environment); - // Step 5: 临界方案(概率阈值 50%) + // Step 5: 临界方案 result.Critical = DeriveCritical(result.Best); return result; @@ -91,111 +89,19 @@ namespace CounterDrone.Core.Algorithms } // ═══════════════════════════════════════════════ - // Step 3: 候选生成 - // ═══════════════════════════════════════════════ - - private List GenerateCandidates(DroneGroup threat, - List availableUnits, CombatScene env) - { - var candidates = new List(); - var neededAmmo = MatchAmmo((PowerType)threat.Target.PowerType); - - foreach (var unit in availableUnits) - { - // 弹药精确匹配,不降级 - if (!unit.AmmoTypes.Contains(neededAmmo)) continue; - - var candidate = unit.Type == PlatformType.AirBased - ? BuildAirBasedCandidate(threat, unit, neededAmmo, env) - : BuildGroundBasedCandidate(threat, unit, neededAmmo, env); - - if (candidate != null) candidates.Add(candidate); - } - - return candidates; - } - - private InterceptCandidate? BuildGroundBasedCandidate(DroneGroup threat, - FireUnit unit, AerosolType ammoType, CombatScene env) - { - var mid = ThreatMidpoint(threat); - float dx = mid.X - unit.Position.X; - float dz = mid.Z - unit.Position.Z; - float dist = (float)Math.Sqrt(dx * dx + dz * dz); - - // 简单射程校验 - float maxRange = unit.MuzzleVelocity * unit.MuzzleVelocity / 9.81f; - if (dist > maxRange * 0.8f) return null; - - float shellTime = dist / unit.MuzzleVelocity; - float interceptTime = threat.ArrivalTime; - - // 计算覆盖率(简化) - var ammo = _ammoCatalog.FirstOrDefault(a => a.AerosolType == (int)ammoType); - float coverage = EstimateCoverage(threat, ammo, env); - - return new InterceptCandidate - { - Unit = unit, - AmmoType = ammoType, - EarliestInterceptTime = interceptTime, - CoverageDuration = coverage, - KillProbability = EstimateKillProbability(threat, coverage), - FlightTime = shellTime, - ShellFlightTime = shellTime, - }; - } - - private InterceptCandidate? BuildAirBasedCandidate(DroneGroup threat, - FireUnit unit, AerosolType ammoType, CombatScene env) - { - var mid = ThreatMidpoint(threat); - float releaseAlt = unit.ReleaseAltitude > 0 ? unit.ReleaseAltitude : (float)threat.Target.TypicalAltitude + 500f; - float flightDist = Kinematics.Distance3D( - unit.Position.X, unit.Position.Y, unit.Position.Z, - mid.X, releaseAlt, mid.Z); - float cruiseSpd = unit.CruiseSpeed > 0.1f ? unit.CruiseSpeed : 55f; - float flightTime = flightDist / cruiseSpd; - float fallTime = Kinematics.AirDropFallTime(releaseAlt, (float)threat.Target.TypicalAltitude); - float totalTime = flightTime + fallTime; - - // 检查能否在威胁到达前完成拦截 - float interceptTime = threat.ArrivalTime; - if (totalTime > interceptTime * 1.5f) return null; - - var ammo = _ammoCatalog.FirstOrDefault(a => a.AerosolType == (int)ammoType); - float coverage = EstimateCoverage(threat, ammo, env); - - return new InterceptCandidate - { - Unit = unit, - AmmoType = ammoType, - EarliestInterceptTime = interceptTime, - CoverageDuration = coverage, - KillProbability = EstimateKillProbability(threat, coverage), - FlightTime = totalTime, - ShellFlightTime = fallTime, - }; - } - - // ═══════════════════════════════════════════════ - // Step 4: 贪心分配 + // Step 3-4: 候选生成 → 贪心分配 // ═══════════════════════════════════════════════ private DefensePlan Solve(List sortedThreats, List fireUnits, CombatScene env) { var plan = new DefensePlan(); - // 工作副本:追踪每次分配的弹药消耗 var remainingMunitions = new Dictionary(); foreach (var u in fireUnits) remainingMunitions[u.Id] = u.TotalMunitions; - var busyUntil = new Dictionary(); - foreach (var threat in sortedThreats) { - // 过滤可用单元:有弹药、冷却完毕 var available = fireUnits .Where(u => remainingMunitions.GetValueOrDefault(u.Id, 0) > 0) .ToList(); @@ -213,16 +119,14 @@ namespace CounterDrone.Core.Algorithms .ThenByDescending(c => c.KillProbability) .First(); - // 计算弹药数 var ammo = _ammoCatalog.FirstOrDefault(a => a.AerosolType == (int)best.AmmoType); int roundsNeeded = CalcRoundsNeeded(threat, ammo, env, best.Unit.Type == PlatformType.AirBased); int actualRounds = Math.Min(roundsNeeded, remainingMunitions[best.Unit.Id]); if (actualRounds <= 0) { plan.ThreatsUnengaged++; continue; } - // 生成发射事件 var fireEvents = GenerateFireEvents(threat, best.Unit, best.AmmoType, - actualRounds, ammo, env, best.FlightTime); + actualRounds, ammo, env); plan.Assignments.Add(new UnitAssignment { @@ -235,28 +139,264 @@ namespace CounterDrone.Core.Algorithms }); plan.MergedSchedule.AddRange(fireEvents); - remainingMunitions[best.Unit.Id] -= actualRounds; - busyUntil[best.Unit.Id] = fireEvents.Count > 0 - ? fireEvents.Max(f => f.FireTime) + best.Unit.Cooldown - : 0; - plan.ThreatsEngaged++; } plan.MergedSchedule.Sort((a, b) => a.FireTime.CompareTo(b.FireTime)); plan.OverallProbability = plan.Assignments.Count > 0 - ? plan.Assignments.Average(a => EstimateKillProbability( - sortedThreats.First(t => t.GroupId == a.DroneGroupId), - a.RoundsFired * 4f)) // 简化:每发约 4 秒覆盖 + ? (float)plan.Assignments.Average(a => + { + var t = sortedThreats.First(th => th.GroupId == a.DroneGroupId); + var a2 = _ammoCatalog.FirstOrDefault(s => s.AerosolType == (int)a.AmmoType); + return ComputeInterceptProbability(t, a2, a.RoundsFired, env); + }) : 0f; plan.Summary = plan.ThreatsEngaged > 0 - ? $"分配 {plan.ThreatsEngaged} 个威胁,{plan.ThreatsUnengaged} 个无方案,总概率 {plan.OverallProbability:P0}" + ? $"分配 {plan.ThreatsEngaged} 个威胁,{plan.ThreatsUnengaged} 个无方案" : "无威胁被分配拦截方案"; return plan; } + // ═══════════════════════════════════════════════ + // 候选生成(使用真实物理) + // ═══════════════════════════════════════════════ + + private List GenerateCandidates(DroneGroup threat, + List availableUnits, CombatScene env) + { + var candidates = new List(); + var neededAmmo = MatchAmmo((PowerType)threat.Target.PowerType); + var ammo = _ammoCatalog.FirstOrDefault(a => a.AerosolType == (int)neededAmmo); + if (ammo == null) return candidates; + + var ammoEff = ComputeEffectiveRadius(threat, ammo, env); + + foreach (var unit in availableUnits) + { + if (!unit.AmmoTypes.Contains(neededAmmo)) continue; + + if (unit.Type == PlatformType.AirBased) + { + var c = BuildAirBasedCandidate(threat, unit, neededAmmo, ammo, ammoEff, env); + if (c != null) candidates.Add(c); + } + else + { + var c = BuildGroundBasedCandidate(threat, unit, neededAmmo, ammo, ammoEff, env); + if (c != null) candidates.Add(c); + } + } + + return candidates; + } + + private InterceptCandidate? BuildGroundBasedCandidate(DroneGroup threat, + FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo, + (float radius, float expTime) ammoEff, CombatScene env) + { + var mid = ThreatMidpoint(threat); + float dx = mid.X - unit.Position.X; + float dz = mid.Z - unit.Position.Z; + float dist = (float)Math.Sqrt(dx * dx + dz * dz); + + float maxRange = unit.MuzzleVelocity * unit.MuzzleVelocity / 9.81f; + if (dist > maxRange * 0.8f) return null; + + float shellTime = dist / unit.MuzzleVelocity; + float interceptTime = threat.ArrivalTime; + + float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f; + float neededExposure = ammoType == AerosolType.ActiveMaterial ? 2f : 6f; + float actualExposure = ammoEff.radius * 2f / avgSpeed; + float prob = Math.Min(0.95f, actualExposure / neededExposure); + + return new InterceptCandidate + { + Unit = unit, + AmmoType = ammoType, + EarliestInterceptTime = interceptTime, + CoverageDuration = ammoEff.radius * 2f, + KillProbability = prob, + FlightTime = shellTime, + ShellFlightTime = shellTime, + }; + } + + private InterceptCandidate? BuildAirBasedCandidate(DroneGroup threat, + FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo, + (float radius, float expTime) ammoEff, CombatScene env) + { + var mid = ThreatMidpoint(threat); + float releaseAlt = unit.ReleaseAltitude > 0 ? unit.ReleaseAltitude : (float)threat.Target.TypicalAltitude + 500f; + float flightDist = Kinematics.Distance3D( + unit.Position.X, unit.Position.Y, unit.Position.Z, + mid.X, releaseAlt, mid.Z); + float cruiseSpd = unit.CruiseSpeed > 0.1f ? unit.CruiseSpeed : 55f; + float flightTime = flightDist / cruiseSpd; + float fallTime = Kinematics.AirDropFallTime(releaseAlt, (float)threat.Target.TypicalAltitude); + float totalTime = flightTime + fallTime; + + float interceptTime = threat.ArrivalTime; + if (totalTime > interceptTime * 1.5f) return null; + + float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f; + float neededExposure = ammoType == AerosolType.ActiveMaterial ? 2f : 6f; + float actualExposure = ammoEff.radius * 2f / avgSpeed; + float prob = Math.Min(0.95f, actualExposure / neededExposure); + + return new InterceptCandidate + { + Unit = unit, + AmmoType = ammoType, + EarliestInterceptTime = interceptTime, + CoverageDuration = ammoEff.radius * 2f, + KillProbability = prob, + FlightTime = totalTime, + ShellFlightTime = fallTime, + }; + } + + // ═══════════════════════════════════════════════ + // 弹药计算(使用 AmmunitionSpec + 环境参数) + // ═══════════════════════════════════════════════ + + private (float effectiveRadius, float expansionTime) ComputeEffectiveRadius( + DroneGroup threat, AmmunitionSpec ammo, CombatScene env) + { + float totalFlightTime = threat.ArrivalTime * 2f; + float halfTime = totalFlightTime / 2f; + float windSpeed = (float)env.WindSpeed; + var weather = (WeatherType)env.WeatherType; + float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f; + + // Phase 1 初始半径(基于爆发药 TNT 当量) + float r0 = 3.3f * (float)Math.Pow(Math.Max(0.01, (float)ammo.BurstChargeKg), 0.32); + float k = (float)ammo.TurbulentExpansionK; + float maxDur = (float)ammo.MaxDuration; + + // Phase 2 湍流膨胀后的半径 + float phase2Time = Math.Min(halfTime, 30f); + float rPhase2 = r0 + k * (float)Math.Sqrt(Math.Max(0, phase2Time)); + + float expansionTime = (float)Math.Pow((rPhase2 - r0) / Math.Max(k, 0.01f), 2); + float effectiveR = rPhase2; + + // Phase 3 高斯扩散 + if (halfTime > 30f) + { + float x = Math.Max(1f, windSpeed * (halfTime - 30f)); + var cls = Kinematics.GetStabilityClass(weather, windSpeed); + float sY = Kinematics.SigmaY(cls, x); + float sZ = Kinematics.SigmaZ(cls, x); + float peakC = Kinematics.GaussianPeakConcentration((float)ammo.SourceStrength, sY, sZ); + float threshold = (float)ammo.EffectiveConcentration; + if (peakC > threshold) + { + float sigma = (sY + sZ) / 2f; + effectiveR = rPhase2 + sigma * (float)Math.Sqrt(2f * Math.Log(peakC / threshold)); + } + } + + return (effectiveR, expansionTime); + } + + private int CalcRoundsNeeded(DroneGroup threat, AmmunitionSpec? ammo, + CombatScene env, bool isAirBased) + { + if (ammo == null) return 1; + + float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f; + var (effectiveR, _) = ComputeEffectiveRadius(threat, ammo, env); + float spacing = effectiveR * 1.5f; + + var aerosolType = (AerosolType)ammo.AerosolType; + float neededExposure = aerosolType == AerosolType.ActiveMaterial ? 2f : 6f; + float requiredCoverage = neededExposure * avgSpeed; + + return Math.Max(1, + (int)Math.Ceiling((requiredCoverage - 2f * effectiveR) / spacing) + 1); + } + + private float ComputeInterceptProbability(DroneGroup threat, + AmmunitionSpec? ammo, int rounds, CombatScene env) + { + if (ammo == null) return 0f; + + float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f; + var (effectiveR, _) = ComputeEffectiveRadius(threat, ammo, env); + float spacing = effectiveR * 1.5f; + float actualCoverage = spacing * (rounds - 1) + 2f * effectiveR; + float actualExposure = actualCoverage / avgSpeed; + + var aerosolType = (AerosolType)ammo.AerosolType; + float neededExposure = aerosolType == AerosolType.ActiveMaterial ? 2f : 6f; + + return Math.Min(0.95f, actualExposure / neededExposure); + } + + // ═══════════════════════════════════════════════ + // 发射事件生成(真实物理) + // ═══════════════════════════════════════════════ + + private List GenerateFireEvents(DroneGroup threat, FireUnit unit, + AerosolType ammoType, int rounds, AmmunitionSpec? ammo, CombatScene env) + { + var events = new List(); + if (ammo == null) return events; + + var mid = ThreatMidpoint(threat); + var (effectiveR, expansionTime) = ComputeEffectiveRadius(threat, ammo, env); + float spacing = effectiveR * 1.5f; + + // 推荐时机:威胁到达中点时云团应已形成 + float recommendedTiming = threat.ArrivalTime - expansionTime; + if (recommendedTiming < 0.1f) recommendedTiming = 0.1f; + + for (int i = 0; i < rounds; i++) + { + float offset = (i - (rounds - 1) / 2f) * spacing; + float tx = mid.X + offset; + float tz = mid.Z; + float fireTime; + + if (unit.Type == PlatformType.AirBased) + { + float releaseAlt = unit.ReleaseAltitude > 0 ? unit.ReleaseAltitude : (float)threat.Target.TypicalAltitude + 500f; + float flightDist = Kinematics.Distance3D( + unit.Position.X, unit.Position.Y, unit.Position.Z, + tx, releaseAlt, tz); + float cruiseSpd = unit.CruiseSpeed > 0.1f ? unit.CruiseSpeed : 55f; + float flightTime = flightDist / cruiseSpd; + float fallTime = Kinematics.AirDropFallTime(releaseAlt, (float)threat.Target.TypicalAltitude); + fireTime = recommendedTiming - flightTime - fallTime; + } + else + { + float dx = tx - unit.Position.X; + float dz = tz - unit.Position.Z; + float dist = (float)Math.Sqrt(dx * dx + dz * dz); + float shellTime = dist / (unit.MuzzleVelocity > 0.1f ? unit.MuzzleVelocity : 800f); + fireTime = recommendedTiming - shellTime; + } + + if (fireTime < 0.1f) fireTime = 0.1f; + + events.Add(new FireEvent + { + FireTime = fireTime, + PlatformIndex = i % rounds, + TargetX = tx, + TargetY = mid.Y, + TargetZ = tz, + MuzzleVelocity = unit.MuzzleVelocity, + }); + } + + return events; + } + // ═══════════════════════════════════════════════ // Step 5: 临界方案(概率阈值 50%) // ═══════════════════════════════════════════════ @@ -265,20 +405,17 @@ namespace CounterDrone.Core.Algorithms { var critical = new DefensePlan { - Assignments = new List(), - MergedSchedule = new List(), ThreatsEngaged = best.ThreatsEngaged, ThreatsUnengaged = best.ThreatsUnengaged, }; - // 逐次减弹药直到概率跌破 50% foreach (var assignment in best.Assignments) { int rounds = assignment.RoundsFired; while (rounds > 1) { - float prob = rounds / (float)assignment.RoundsFired * 0.95f; - if (prob < 0.5f) break; + // 简化:弹药减半 → 概率减半 + if ((float)rounds / assignment.RoundsFired < 0.5f) break; rounds--; } @@ -302,7 +439,7 @@ namespace CounterDrone.Core.Algorithms } // ═══════════════════════════════════════════════ - // 辅助方法 + // 辅助 // ═══════════════════════════════════════════════ private static Vector3 ThreatMidpoint(DroneGroup threat) @@ -316,74 +453,5 @@ namespace CounterDrone.Core.Algorithms (float)(s.PosY + e.PosY) / 2f, (float)(s.PosZ + e.PosZ) / 2f); } - - private static float EstimateCoverage(DroneGroup threat, AmmunitionSpec? ammo, CombatScene env) - { - if (ammo == null) return 4f; - // 简化:基于弹药最大持续时间和有效半径 - return Math.Min((float)ammo.MaxDuration, 60f); - } - - private static float EstimateKillProbability(DroneGroup threat, float coverageDuration) - { - if (threat.Waypoints.Count < 2) return 0f; - float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f; - float exposureTime = coverageDuration / avgSpeed; - return Math.Min(0.95f, exposureTime / 6f); // 6s 暴露 = 95% - } - - private static int CalcRoundsNeeded(DroneGroup threat, AmmunitionSpec? ammo, CombatScene env, bool isAirBased) - { - if (ammo == null) return 1; - float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f; - float neededExposure = 6f; - float effectiveR = 80f; // 简化默认值 - float spacing = effectiveR * 1.5f; - float required = neededExposure * avgSpeed; - return Math.Max(1, (int)Math.Ceiling((required - 2f * effectiveR) / spacing) + 1); - } - - private List GenerateFireEvents(DroneGroup threat, FireUnit unit, - AerosolType ammoType, int rounds, AmmunitionSpec? ammo, CombatScene env, float flightTime) - { - var events = new List(); - var mid = ThreatMidpoint(threat); - float effectiveR = 80f; - float spacing = effectiveR * 1.5f; - float interceptTime = threat.ArrivalTime; - - for (int i = 0; i < rounds; i++) - { - float offset = (i - (rounds - 1) / 2f) * spacing; - float fireTime; - - if (unit.Type == PlatformType.AirBased) - { - // FireTime = 拦截时机 − 平台飞行时间 − 弹药下落时间 - float fallTime = Kinematics.AirDropFallTime( - unit.ReleaseAltitude > 0 ? unit.ReleaseAltitude : (float)threat.Target.TypicalAltitude + 500f, - (float)threat.Target.TypicalAltitude); - fireTime = interceptTime - flightTime - fallTime; - } - else - { - fireTime = interceptTime - flightTime; // flightTime = shell time - } - - if (fireTime < 0.1f) fireTime = 0.1f; - - events.Add(new FireEvent - { - FireTime = fireTime, - PlatformIndex = i % rounds, - TargetX = mid.X + offset, - TargetY = mid.Y, - TargetZ = mid.Z, - MuzzleVelocity = unit.MuzzleVelocity, - }); - } - - return events; - } } } diff --git a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs index 1a297f6..274137c 100644 --- a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs +++ b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs @@ -85,7 +85,8 @@ namespace CounterDrone.Core.Tests new List { MakeThreat() }, new CombatScene()); Assert.True(result.Best.ThreatsEngaged > 0); - Assert.True(result.Critical.ThreatsEngaged > 0); + Assert.True(result.Best.OverallProbability >= result.Critical.OverallProbability, + $"Best={result.Best.OverallProbability:P0} Critical={result.Critical.OverallProbability:P0}"); } [Fact] @@ -139,5 +140,292 @@ namespace CounterDrone.Core.Tests new CombatScene()); Assert.Equal(0, result.Best.ThreatsEngaged); } + + // ═══════════════════════════════════════════════ + // 威胁排序 + // ═══════════════════════════════════════════════ + + [Fact] + public void ThreatPriority_SpeedMatters() + { + var slow = MakeThreat(PowerType.Piston, 60); + slow.Target.TargetType = (int)TargetType.Piston; + slow.ArrivalTime = 100; + slow.ThreatIndex = CalcThreatIndexStatic(slow.Target); + + var fast = MakeThreat(PowerType.Piston, 500); + fast.Target.TargetType = (int)TargetType.Piston; + fast.ArrivalTime = 100; + fast.ThreatIndex = CalcThreatIndexStatic(fast.Target); + + Assert.True(fast.Priority > slow.Priority, $"fast={fast.Priority} slow={slow.Priority}"); + } + + [Fact] + public void ThreatPriority_TypeMatters() + { + var rotor = MakeThreat(PowerType.Electric, 200); + rotor.Target.TargetType = (int)TargetType.Rotor; + rotor.ArrivalTime = 50; + rotor.ThreatIndex = CalcThreatIndexStatic(rotor.Target); + + var jet = MakeThreat(PowerType.Piston, 200); + jet.Target.TargetType = (int)TargetType.HighSpeed; + jet.ArrivalTime = 50; + jet.ThreatIndex = CalcThreatIndexStatic(jet.Target); + + Assert.True(jet.ThreatIndex > rotor.ThreatIndex, + $"jet={jet.ThreatIndex} rotor={rotor.ThreatIndex}"); + } + + private static float CalcThreatIndexStatic(TargetConfig target) + { + var typeCoef = new Dictionary + { + { TargetType.HighSpeed, 4f }, { TargetType.FixedWing, 2f }, + { TargetType.Piston, 2f }, { TargetType.Rotor, 1f }, { TargetType.Electric, 1f }, + }.GetValueOrDefault((TargetType)target.TargetType, 1f); + return typeCoef * (float)target.TypicalSpeed / 60f; + } + + // ═══════════════════════════════════════════════ + // 弹药匹配 + // ═══════════════════════════════════════════════ + + [Fact] + public void AmmoMatch_Piston_GetsInertGas() + { + var result = new DefaultDefensePlanner(TestAmmo).Plan( + MakeGroundUnits(), + new List { MakeThreat(PowerType.Piston) }, + new CombatScene()); + Assert.Equal(AerosolType.InertGas, result.Best.Assignments[0].AmmoType); + } + + [Fact] + public void AmmoMatch_Jet_GetsActiveMaterial() + { + var result = new DefaultDefensePlanner(TestAmmo).Plan( + MakeGroundUnits(), + new List { MakeThreat(PowerType.Jet, 500) }, + new CombatScene()); + Assert.Equal(AerosolType.ActiveMaterial, result.Best.Assignments[0].AmmoType); + } + + // ═══════════════════════════════════════════════ + // 候选过滤 + // ═══════════════════════════════════════════════ + + [Fact] + public void IncompatibleAmmo_NotEngaged() + { + var units = new List + { + new FireUnit { Id = "u0", Type = PlatformType.GroundBased, + Position = new Vector3(5000, 0, 50), MuzzleVelocity = 800, + TotalMunitions = 3, + AmmoTypes = new() { AerosolType.ActiveMaterial } }, // 只有活性材料 + }; + // 活塞需要惰性气体 → 不兼容 + var result = new DefaultDefensePlanner(TestAmmo).Plan( + units, + new List { MakeThreat(PowerType.Piston) }, + new CombatScene()); + Assert.Equal(0, result.Best.ThreatsEngaged); + Assert.Equal(1, result.Best.ThreatsUnengaged); + } + + [Fact] + public void OutOfRange_NotEngaged() + { + var units = new List + { + new FireUnit { Id = "u0", Type = PlatformType.GroundBased, + Position = new Vector3(100000, 0, 100000), MuzzleVelocity = 100, // 极远 + 极慢 + TotalMunitions = 3, + AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial } }, + }; + var result = new DefaultDefensePlanner(TestAmmo).Plan( + units, + new List { MakeThreat(PowerType.Piston) }, + new CombatScene()); + Assert.Equal(0, result.Best.ThreatsEngaged); + } + + // ═══════════════════════════════════════════════ + // 弹药数计算 + // ═══════════════════════════════════════════════ + + [Fact] + public void RoundsNeeded_IncreasesWithSpeed() + { + var slow = MakeThreat(PowerType.Piston, 60); + var fast = MakeThreat(PowerType.Piston, 300); + + var planner = new DefaultDefensePlanner(TestAmmo); + var r1 = planner.Plan(MakeGroundUnits(10), new() { slow }, new CombatScene()); + var r2 = planner.Plan(MakeGroundUnits(10), new() { fast }, new CombatScene()); + + // 高速威胁需要更多弹药覆盖 + Assert.True(r2.Best.Assignments[0].RoundsFired >= r1.Best.Assignments[0].RoundsFired, + $"slow={r1.Best.Assignments[0].RoundsFired} fast={r2.Best.Assignments[0].RoundsFired}"); + } + + [Fact] + public void RoundsNeeded_CappedByMunitionCount() + { + var units = new List + { + new FireUnit { Id = "u0", Type = PlatformType.GroundBased, + Position = new Vector3(5000, 0, 50), MuzzleVelocity = 800, + TotalMunitions = 1, // 只能打 1 发 + AmmoTypes = new() { AerosolType.InertGas } }, + }; + var result = new DefaultDefensePlanner(TestAmmo).Plan( + units, + new List { MakeThreat(PowerType.Piston, 300) }, + new CombatScene()); + Assert.True(result.Best.ThreatsEngaged > 0); + Assert.Equal(1, result.Best.Assignments[0].RoundsFired); + } + + // ═══════════════════════════════════════════════ + // 发射时机 + // ═══════════════════════════════════════════════ + + [Fact] + public void FireTime_Positive() + { + var result = new DefaultDefensePlanner(TestAmmo).Plan( + MakeGroundUnits(10), + new List { MakeThreat() }, + new CombatScene()); + Assert.NotEmpty(result.Best.MergedSchedule); + Assert.All(result.Best.MergedSchedule, fe => Assert.True(fe.FireTime > 0)); + } + + [Fact] + public void FireTime_BeforeArrivalTime() + { + var threat = MakeThreat(PowerType.Piston, 120); + var result = new DefaultDefensePlanner(TestAmmo).Plan( + MakeGroundUnits(10), + new List { threat }, + new CombatScene()); + // 所有发射时机应在威胁到达之前 + float arrivalTime = threat.GetArrivalTime(); + Assert.All(result.Best.MergedSchedule, fe => + Assert.True(fe.FireTime < arrivalTime, + $"FireTime={fe.FireTime:F0} >= ArrivalTime={arrivalTime:F0}")); + } + + [Fact] + public void FireEvents_TargetsOnRoute() + { + var threat = MakeThreat(PowerType.Piston, 120); + var result = new DefaultDefensePlanner(TestAmmo).Plan( + MakeGroundUnits(10), + new List { threat }, + new CombatScene()); + // 目标点应在航路起点和终点之间 + Assert.All(result.Best.MergedSchedule, fe => + { + Assert.True(fe.TargetX >= 0 && fe.TargetX <= 10000, + $"TargetX={fe.TargetX:F0} out of [0,10000]"); + }); + } + + // ═══════════════════════════════════════════════ + // 多威胁分配 + // ═══════════════════════════════════════════════ + + [Fact] + public void MultiThreat_BothEngaged() + { + var threats = new List + { + MakeThreat(PowerType.Piston, 120), + MakeThreat(PowerType.Jet, 500), + }; + threats[0].GroupId = "g0"; + threats[1].GroupId = "g1"; + + var result = new DefaultDefensePlanner(TestAmmo).Plan( + MakeGroundUnits(10), + threats, + new CombatScene()); + + Assert.Equal(2, result.Best.ThreatsEngaged); + Assert.Equal(0, result.Best.ThreatsUnengaged); + } + + [Fact] + public void MultiThreat_LimitedUnits_SomeUnengaged() + { + var threats = new List + { + MakeThreat(PowerType.Piston, 120), + MakeThreat(PowerType.Jet, 500), + MakeThreat(PowerType.Electric, 100), + }; + threats[0].GroupId = "g0"; + threats[1].GroupId = "g1"; + threats[2].GroupId = "g2"; + + var units = new List + { + new FireUnit { Id = "u0", Type = PlatformType.GroundBased, + Position = new Vector3(5000, 0, 50), MuzzleVelocity = 800, + TotalMunitions = 1, + AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel } }, + }; + + var result = new DefaultDefensePlanner(TestAmmo).Plan(units, threats, new CombatScene()); + Assert.True(result.Best.ThreatsEngaged > 0); + Assert.True(result.Best.ThreatsUnengaged > 0); + } + + // ═══════════════════════════════════════════════ + // 空基平台 + // ═══════════════════════════════════════════════ + + [Fact] + public void AirBased_Engaged() + { + var units = new List + { + new FireUnit { Id = "u0", Type = PlatformType.AirBased, + Position = new Vector3(3000, 2500, -2000), CruiseSpeed = 55, + ReleaseAltitude = 1500, TotalMunitions = 3, + AmmoTypes = new() { AerosolType.InertGas } }, + }; + var result = new DefaultDefensePlanner(TestAmmo).Plan( + units, + new List { MakeThreat(PowerType.Piston) }, + new CombatScene()); + Assert.True(result.Best.ThreatsEngaged > 0); + Assert.NotEmpty(result.Best.MergedSchedule); + } + + [Fact] + public void AirBased_FireTime_EarlierThanArrival() + { + var units = new List + { + new FireUnit { Id = "u0", Type = PlatformType.AirBased, + Position = new Vector3(3000, 2500, -2000), CruiseSpeed = 55, + ReleaseAltitude = 1500, TotalMunitions = 3, + AmmoTypes = new() { AerosolType.InertGas } }, + }; + var threat = MakeThreat(PowerType.Piston, 120); + var result = new DefaultDefensePlanner(TestAmmo).Plan( + units, + new List { threat }, + new CombatScene()); + float arrivalTime = threat.GetArrivalTime(); + Assert.All(result.Best.MergedSchedule, fe => + Assert.True(fe.FireTime < arrivalTime, + $"FireTime={fe.FireTime:F0} >= ArrivalTime={arrivalTime:F0}")); + } } }