From b957a5e2b45a9b46954beccffa270775d16317a3 Mon Sep 17 00:00:00 2001
From: tian <11429339@qq.com>
Date: Sat, 13 Jun 2026 09:30:04 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20Planner=20=E4=BD=BF=E7=94=A8=E7=9C=9F?=
=?UTF-8?q?=E5=AE=9E=E7=89=A9=E7=90=86=E8=AE=A1=E7=AE=97=20+=20=E5=AE=8C?=
=?UTF-8?q?=E5=96=84=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=20+=20=E8=A7=84?=
=?UTF-8?q?=E5=88=997?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ComputeEffectiveRadius: 基于 AmmunitionSpec 三阶段扩散模型
- CalcRoundsNeeded: 真实有效半径和弹药参数
- GenerateFireEvents: recommendedTiming = arrivalTime - expansionTime
- 候选概率基于真实覆盖计算
- 24 个单元测试覆盖威胁排序/弹药匹配/候选过滤/弹药数/时机/多威胁/空基
- AGENTS.md 规则7: 只跑相关测试,不跑全量
---
AGENTS.md | 14 +
.../Algorithms/DefaultDefensePlanner.cs | 436 ++++++++++--------
.../DefensePlannerTests.cs | 290 +++++++++++-
3 files changed, 555 insertions(+), 185 deletions(-)
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}"));
+ }
}
}