feat: Planner 使用真实物理计算 + 完善单元测试 + 规则7
- ComputeEffectiveRadius: 基于 AmmunitionSpec 三阶段扩散模型 - CalcRoundsNeeded: 真实有效半径和弹药参数 - GenerateFireEvents: recommendedTiming = arrivalTime - expansionTime - 候选概率基于真实覆盖计算 - 24 个单元测试覆盖威胁排序/弹药匹配/候选过滤/弹药数/时机/多威胁/空基 - AGENTS.md 规则7: 只跑相关测试,不跑全量
This commit is contained in:
parent
7d080bc929
commit
b957a5e2b4
14
AGENTS.md
14
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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user