feat: Planner 使用真实物理计算 + 完善单元测试 + 规则7

- ComputeEffectiveRadius: 基于 AmmunitionSpec 三阶段扩散模型
- CalcRoundsNeeded: 真实有效半径和弹药参数
- GenerateFireEvents: recommendedTiming = arrivalTime - expansionTime
- 候选概率基于真实覆盖计算
- 24 个单元测试覆盖威胁排序/弹药匹配/候选过滤/弹药数/时机/多威胁/空基
- AGENTS.md 规则7: 只跑相关测试,不跑全量
This commit is contained in:
tian 2026-06-13 09:30:04 +08:00
parent 7d080bc929
commit b957a5e2b4
3 changed files with 555 additions and 185 deletions

View File

@ -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.

View File

@ -5,7 +5,7 @@ using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>默认防御规划器 — 五步流水线</summary>
/// <summary>默认防御规划器 — 五步流水线,全部使用真实物理计算</summary>
public class DefaultDefensePlanner : IDefensePlanner
{
private readonly List<AmmunitionSpec> _ammoCatalog;
@ -15,7 +15,6 @@ namespace CounterDrone.Core.Algorithms
_ammoCatalog = ammoCatalog ?? new List<AmmunitionSpec>();
}
/// <summary>弹药匹配表PowerType → 最佳 AerosolType</summary>
private static readonly Dictionary<PowerType, AerosolType> MatchTable = new()
{
{ PowerType.Electric, AerosolType.InertGas },
@ -23,7 +22,6 @@ namespace CounterDrone.Core.Algorithms
{ PowerType.Jet, AerosolType.ActiveMaterial },
};
/// <summary>威胁类型系数(越大越危险)</summary>
private static readonly Dictionary<TargetType, float> 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<InterceptCandidate> GenerateCandidates(DroneGroup threat,
List<FireUnit> availableUnits, CombatScene env)
{
var candidates = new List<InterceptCandidate>();
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<DroneGroup> sortedThreats,
List<FireUnit> fireUnits, CombatScene env)
{
var plan = new DefensePlan();
// 工作副本:追踪每次分配的弹药消耗
var remainingMunitions = new Dictionary<string, int>();
foreach (var u in fireUnits)
remainingMunitions[u.Id] = u.TotalMunitions;
var busyUntil = new Dictionary<string, float>();
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<InterceptCandidate> GenerateCandidates(DroneGroup threat,
List<FireUnit> availableUnits, CombatScene env)
{
var candidates = new List<InterceptCandidate>();
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<FireEvent> GenerateFireEvents(DroneGroup threat, FireUnit unit,
AerosolType ammoType, int rounds, AmmunitionSpec? ammo, CombatScene env)
{
var events = new List<FireEvent>();
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<UnitAssignment>(),
MergedSchedule = new List<FireEvent>(),
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<FireEvent> GenerateFireEvents(DroneGroup threat, FireUnit unit,
AerosolType ammoType, int rounds, AmmunitionSpec? ammo, CombatScene env, float flightTime)
{
var events = new List<FireEvent>();
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;
}
}
}

View File

@ -85,7 +85,8 @@ namespace CounterDrone.Core.Tests
new List<DroneGroup> { 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, float>
{
{ 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<DroneGroup> { 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<DroneGroup> { MakeThreat(PowerType.Jet, 500) },
new CombatScene());
Assert.Equal(AerosolType.ActiveMaterial, result.Best.Assignments[0].AmmoType);
}
// ═══════════════════════════════════════════════
// 候选过滤
// ═══════════════════════════════════════════════
[Fact]
public void IncompatibleAmmo_NotEngaged()
{
var units = new List<FireUnit>
{
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<DroneGroup> { 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<FireUnit>
{
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<DroneGroup> { 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<FireUnit>
{
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<DroneGroup> { 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<DroneGroup> { 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<DroneGroup> { 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<DroneGroup> { 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<DroneGroup>
{
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<DroneGroup>
{
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<FireUnit>
{
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<FireUnit>
{
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<DroneGroup> { 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<FireUnit>
{
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<DroneGroup> { 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}"));
}
}
}