feat: 火力单元通道模型 + 默认配置 + 引擎通道展开

- FireUnit: 新增 GunCount/ChannelsPerGun/ChannelInterval/检测设备字段
- EquipmentDeployment: 新增 GunCount/ChannelsPerGun/ChannelInterval
- DefaultFireUnits: 3 种默认火力单元(标准地基/重地基/空基)
- Planner: 逐通道齐射分配,ChannelInterval 错开,Cooldown 轮次
- Engine: PlatformEntity 按 GunCount×ChannelsPerGun 展开
- BuildFireUnits: 与引擎同步展开通道
- FullPipelineTests: 使用 DefaultFireUnits 配置
This commit is contained in:
tian 2026-06-13 10:42:08 +08:00
parent 9bd1d5a7ee
commit c08edbd2f9
8 changed files with 441 additions and 405 deletions

View File

@ -0,0 +1,48 @@
[
{
"Id": "ground-standard",
"Name": "标准地基火力单元",
"PlatformType": 1,
"GunCount": 4,
"ChannelsPerGun": 4,
"ChannelInterval": 1.0,
"Cooldown": 5.0,
"AmmoChangeTime": 30.0,
"MuzzleVelocity": 800.0,
"AmmoTypes": [0, 1],
"RadarRange": 15000.0,
"EORange": 8000.0,
"IRRange": 5000.0
},
{
"Id": "ground-heavy",
"Name": "重型地基火力单元",
"PlatformType": 1,
"GunCount": 6,
"ChannelsPerGun": 4,
"ChannelInterval": 1.0,
"Cooldown": 5.0,
"AmmoChangeTime": 30.0,
"MuzzleVelocity": 600.0,
"AmmoTypes": [0, 1, 2],
"RadarRange": 20000.0,
"EORange": 10000.0,
"IRRange": 6000.0
},
{
"Id": "air-standard",
"Name": "标准空基火力单元",
"PlatformType": 0,
"GunCount": 1,
"ChannelsPerGun": 4,
"ChannelInterval": 1.0,
"Cooldown": 5.0,
"AmmoChangeTime": 30.0,
"CruiseSpeed": 55.0,
"ReleaseAltitude": 1500.0,
"AmmoTypes": [0, 1],
"RadarRange": 0.0,
"EORange": 12000.0,
"IRRange": 8000.0
}
]

View File

@ -50,28 +50,47 @@ namespace CounterDrone.Core.Algorithms
public List<FireEvent> MergedFireSchedule { get; set; } = new();
}
/// <summary>火力单元(单个平台,统一表达空基/地基</summary>
/// <summary>火力单元(完整的武器系统</summary>
public class FireUnit
{
public string Id { get; set; } = string.Empty;
/// <summary>平台类型AirBased / GroundBased</summary>
public PlatformType Type { get; set; }
/// <summary>待命位置(空基含巡航高度)</summary>
public Vector3 Position { get; set; }
/// <summary>空基巡航速度 m/s</summary>
public float CruiseSpeed { get; set; }
/// <summary>空基投放高度 m</summary>
public float ReleaseAltitude { get; set; }
/// <summary>地基初速 m/s</summary>
public float MuzzleVelocity { get; set; } = 800f;
// ── 发射装置 ──
/// <summary>火炮/发射器数量</summary>
public int GunCount { get; set; } = 1;
/// <summary>每门炮的火力通道数(同时可装填的弹药数量)</summary>
public int ChannelsPerGun { get; set; } = 1;
/// <summary>总火力通道数 = GunCount × ChannelsPerGun</summary>
public int TotalChannels => GunCount * ChannelsPerGun;
/// <summary>同一通道连续发射的最小间隔(秒)</summary>
public float ChannelInterval { get; set; } = 1f;
// ── 弹药 ──
/// <summary>总载弹量</summary>
public int TotalMunitions { get; set; }
/// <summary>可装填的弹药类型</summary>
public List<AerosolType> AmmoTypes { get; set; } = new();
/// <summary>同弹种连发冷却 s</summary>
/// <summary>单通道射击后冷却时间(秒)</summary>
public float Cooldown { get; set; } = 5f;
/// <summary>更换弹种时间 s</summary>
public float AmmoChangeTime { get; set; } = 300f;
/// <summary>更换弹种时间(秒)</summary>
public float AmmoChangeTime { get; set; } = 30f;
// ── 搜索跟踪 ──
/// <summary>雷达探测距离m0 表示无此设备</summary>
public float RadarRange { get; set; }
/// <summary>光电探测距离m</summary>
public float EORange { get; set; }
/// <summary>红外探测距离m</summary>
public float IRRange { get; set; }
// ── 空基 ──
public float CruiseSpeed { get; set; }
public float ReleaseAltitude { get; set; }
// ── 地基 ──
public float MuzzleVelocity { get; set; } = 800f;
}
/// <summary>无人机编队(规划器输入)</summary>

View File

@ -119,22 +119,45 @@ namespace CounterDrone.Core.Algorithms
int totalRoundsNeeded = CalcRoundsNeeded(threat, ammo, env,
candidates[0].Unit.Type == PlatformType.AirBased);
// 从多个候选单元凑弹药,直到满足需求或耗尽
// 计算所需齐射轮数
int totalChannels = candidates.Sum(c => c.Unit.TotalChannels);
int salvosNeeded = (int)Math.Ceiling((float)totalRoundsNeeded / Math.Max(1, totalChannels));
// 逐轮齐射分配
int roundsCollected = 0;
int eventIdx = 0;
var assignedUnits = new List<(FireUnit unit, int rounds, List<FireEvent> events)>();
foreach (var c in candidates.OrderBy(c => c.EarliestInterceptTime)
.ThenByDescending(c => c.KillProbability))
for (int salvo = 0; salvo < salvosNeeded; salvo++)
{
if (roundsCollected >= totalRoundsNeeded) break;
int remaining = remainingMunitions.GetValueOrDefault(c.Unit.Id, 0);
if (remaining <= 0) continue;
float salvoDelay = salvo * candidates[0].Unit.Cooldown;
foreach (var c in candidates.OrderBy(c => c.EarliestInterceptTime)
.ThenByDescending(c => c.KillProbability))
{
if (roundsCollected >= totalRoundsNeeded) break;
int remaining = remainingMunitions.GetValueOrDefault(c.Unit.Id, 0);
if (remaining <= 0) continue;
int toTake = Math.Min(remaining, totalRoundsNeeded - roundsCollected);
var fireEvents = GenerateFireEvents(threat, c.Unit, c.AmmoType, toTake, ammo, env);
assignedUnits.Add((c.Unit, toTake, fireEvents));
remainingMunitions[c.Unit.Id] -= toTake;
roundsCollected += toTake;
int maxSalvo = Math.Min(c.Unit.TotalChannels, remaining);
int toTake = Math.Min(maxSalvo, totalRoundsNeeded - roundsCollected);
if (toTake <= 0) continue;
var (effectiveR, _) = ComputeEffectiveRadius(threat, ammo, env);
float spacing = effectiveR * 1.5f;
var fevents = new List<FireEvent>();
for (int ch = 0; ch < toTake; ch++)
{
float offset = (eventIdx - (totalRoundsNeeded - 1) / 2f) * spacing;
var fe = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, offset);
foreach (var e in fe) e.FireTime += salvoDelay + ch * c.Unit.ChannelInterval;
fevents.AddRange(fe);
eventIdx++;
}
assignedUnits.Add((c.Unit, toTake, fevents));
remainingMunitions[c.Unit.Id] -= toTake;
roundsCollected += toTake;
}
}
if (roundsCollected <= 0) { plan.ThreatsUnengaged++; continue; }
@ -364,61 +387,53 @@ namespace CounterDrone.Core.Algorithms
// 发射事件生成(真实物理)
// ═══════════════════════════════════════════════
private List<FireEvent> GenerateFireEvents(DroneGroup threat, FireUnit unit,
AerosolType ammoType, int rounds, AmmunitionSpec? ammo, CombatScene env)
private List<FireEvent> GenerateFireEventsAt(DroneGroup threat, FireUnit unit,
AerosolType ammoType, AmmunitionSpec? ammo, CombatScene env, float targetOffset)
{
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 tx = mid.X + targetOffset;
float tz = mid.Z;
float fireTime;
if (unit.Type == PlatformType.AirBased)
{
float offset = (i - (rounds - 1) / 2f) * spacing;
float tx = mid.X + offset;
float tz = mid.Z;
float baseFireTime;
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);
baseFireTime = 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);
baseFireTime = recommendedTiming - shellTime;
}
// 同一平台的后续弹药错开冷却时间
float fireTime = baseFireTime + i * unit.Cooldown;
if (fireTime < 0.1f) fireTime = 0.1f;
events.Add(new FireEvent
{
FireTime = fireTime,
PlatformIndex = 0, // 由调用方在分配后覆盖为正确索引
TargetX = tx,
TargetY = mid.Y,
TargetZ = tz,
MuzzleVelocity = unit.MuzzleVelocity,
});
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 = 0,
TargetX = tx,
TargetY = mid.Y,
TargetZ = tz,
MuzzleVelocity = unit.MuzzleVelocity,
});
return events;
}

View File

@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Text.Json;
using CounterDrone.Core.Models;
namespace CounterDrone.Core
{
public static class DefaultFireUnits
{
private static List<FireUnitTemplate> _cache;
public static List<FireUnitTemplate> GetAll()
{
if (_cache != null) return _cache;
_cache = new List<FireUnitTemplate>
{
new FireUnitTemplate
{
Id = "ground-standard",
Name = "标准地基火力单元",
PlatformType = 1,
GunCount = 4,
ChannelsPerGun = 4,
ChannelInterval = 1.0,
Cooldown = 5.0,
AmmoChangeTime = 30.0,
MuzzleVelocity = 800.0,
AmmoTypes = new() { 0, 1 },
RadarRange = 15000.0,
EORange = 8000.0,
IRRange = 5000.0,
},
new FireUnitTemplate
{
Id = "ground-heavy",
Name = "重型地基火力单元",
PlatformType = 1,
GunCount = 6,
ChannelsPerGun = 4,
ChannelInterval = 1.0,
Cooldown = 5.0,
AmmoChangeTime = 30.0,
MuzzleVelocity = 600.0,
AmmoTypes = new() { 0, 1, 2 },
RadarRange = 20000.0,
EORange = 10000.0,
IRRange = 6000.0,
},
new FireUnitTemplate
{
Id = "air-standard",
Name = "标准空基火力单元",
PlatformType = 0,
GunCount = 1,
ChannelsPerGun = 4,
ChannelInterval = 1.0,
Cooldown = 5.0,
AmmoChangeTime = 30.0,
CruiseSpeed = 55.0,
ReleaseAltitude = 1500.0,
AmmoTypes = new() { 0, 1 },
EORange = 12000.0,
IRRange = 8000.0,
},
};
return _cache;
}
public static FireUnitTemplate GetById(string id) => GetAll().Find(t => t.Id == id);
}
public class FireUnitTemplate
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public int PlatformType { get; set; }
public int GunCount { get; set; } = 1;
public int ChannelsPerGun { get; set; } = 1;
public double ChannelInterval { get; set; } = 1.0;
public double Cooldown { get; set; } = 5.0;
public double AmmoChangeTime { get; set; } = 30.0;
public double MuzzleVelocity { get; set; }
public double CruiseSpeed { get; set; }
public double ReleaseAltitude { get; set; }
public List<int> AmmoTypes { get; set; } = new();
public double RadarRange { get; set; }
public double EORange { get; set; }
public double IRRange { get; set; }
}
}

View File

@ -30,6 +30,15 @@ namespace CounterDrone.Core.Models
public int? MunitionCount { get; set; }
/// <summary>火炮/发射器数量null=1</summary>
public int? GunCount { get; set; }
/// <summary>每门炮的火力通道数null=1</summary>
public int? ChannelsPerGun { get; set; }
/// <summary>同一通道连发最小间隔秒null=1</summary>
public double? ChannelInterval { get; set; }
public int Source { get; set; } = (int)ConfigSource.Manual;
public double? MuzzleVelocity { get; set; }

View File

@ -102,7 +102,13 @@ namespace CounterDrone.Core.Simulation
foreach (var equip in config.Equipment)
if (equip.EquipmentRole != (int)EquipmentRole.Detection)
for (int i = 0; i < equip.Quantity; i++)
_platforms.Add(new PlatformEntity($"plat_{++_entityCounter}", equip));
{
int gunCount = equip.GunCount ?? 1;
int chPerGun = equip.ChannelsPerGun ?? 1;
for (int g = 0; g < gunCount; g++)
for (int ch = 0; ch < chPerGun; ch++)
_platforms.Add(new PlatformEntity($"plat_{++_entityCounter}", equip));
}
_zones.Clear();
foreach (var z in config.ControlZones)
@ -295,18 +301,27 @@ namespace CounterDrone.Core.Simulation
int qty = Math.Max(1, eq.Quantity);
for (int i = 0; i < qty; i++)
{
units.Add(new FireUnit
{
Id = $"fu{idx++}",
Type = (PlatformType)(eq.PlatformType ?? (int)PlatformType.GroundBased),
Position = new Algorithms.Vector3((float)eq.PositionX + i * 50, (float)eq.PositionY, (float)eq.PositionZ),
CruiseSpeed = (float)(eq.CruiseSpeed ?? 55f),
ReleaseAltitude = (float)(eq.ReleaseAltitude ?? 1000f),
MuzzleVelocity = (float)(eq.MuzzleVelocity ?? 800f),
TotalMunitions = eq.MunitionCount ?? 1,
AmmoTypes = new List<AerosolType> { (AerosolType)(eq.AerosolType ?? 0) },
Cooldown = (float)eq.Cooldown,
});
// 每个火力单元展开为独立通道GunCount × ChannelsPerGun
int gunCount = eq.GunCount ?? 1;
int chPerGun = eq.ChannelsPerGun ?? 1;
for (int g = 0; g < gunCount; g++)
for (int ch = 0; ch < chPerGun; ch++)
{
units.Add(new FireUnit
{
Id = $"fu{idx++}",
Type = (PlatformType)(eq.PlatformType ?? (int)PlatformType.GroundBased),
Position = new Algorithms.Vector3((float)eq.PositionX + i * 50, (float)eq.PositionY, (float)eq.PositionZ),
GunCount = 1, ChannelsPerGun = 1, ChannelInterval = (float)(eq.ChannelInterval ?? 1),
CruiseSpeed = (float)(eq.CruiseSpeed ?? 55f),
ReleaseAltitude = (float)(eq.ReleaseAltitude ?? 1000f),
MuzzleVelocity = (float)(eq.MuzzleVelocity ?? 800f),
// 总弹药均分到每个通道
TotalMunitions = (eq.MunitionCount ?? 1) / (gunCount * chPerGun),
AmmoTypes = new List<AerosolType> { (AerosolType)(eq.AerosolType ?? 0) },
Cooldown = (float)eq.Cooldown,
});
}
}
}
return units;

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using Xunit;
@ -9,7 +10,7 @@ namespace CounterDrone.Core.Tests
{
private static readonly List<AmmunitionSpec> TestAmmo = DefaultAmmunition.GetAll();
private static List<FireUnit> MakeGroundUnits(int count = 5)
private static List<FireUnit> MakeGroundUnits(int count = 2)
{
var units = new List<FireUnit>();
for (int i = 0; i < count; i++)
@ -17,9 +18,13 @@ namespace CounterDrone.Core.Tests
{
Id = $"u{i}",
Type = PlatformType.GroundBased,
Position = new Vector3(5000 + i * 50, 0, 50),
Position = new Vector3(5000 + i * 100, 0, 50),
GunCount = 4,
ChannelsPerGun = 4,
ChannelInterval = 1f,
MuzzleVelocity = 800f,
TotalMunitions = 3,
TotalMunitions = 16,
Cooldown = 5f,
AmmoTypes = new List<AerosolType> { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel },
});
return units;
@ -46,104 +51,9 @@ namespace CounterDrone.Core.Tests
};
}
[Fact]
public void Piston_Engaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Piston) },
new CombatScene { WindSpeed = 5 });
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.Contains("分配", result.Best.Summary);
}
[Fact]
public void Jet_Engaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Jet, 500) },
new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
}
[Fact]
public void Electric_Engaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Electric) },
new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
}
[Fact]
public void Best_HigherThanCritical()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.True(result.Best.OverallProbability >= result.Critical.OverallProbability,
$"Best={result.Best.OverallProbability:P0} Critical={result.Critical.OverallProbability:P0}");
}
[Fact]
public void Has_FireSchedule()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.NotEmpty(result.Best.MergedSchedule);
}
[Fact]
public void Has_Assignments()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.NotEmpty(result.Best.Assignments);
}
[Fact]
public void Critical_Within50Percent()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
// Critical 方案概率标记为 0.5(阈值下限)
Assert.True(result.Critical.OverallProbability >= 0.45f);
Assert.True(result.Critical.Assignments.Count > 0);
}
[Fact]
public void NoUnits_ThreatsUnengaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
new List<FireUnit>(),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.Equal(1, result.Best.ThreatsUnengaged);
}
[Fact]
public void NoThreats_EmptyPlan()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup>(),
new CombatScene());
Assert.Equal(0, result.Best.ThreatsEngaged);
}
// ═══════════════════════════════════════════════
// ═══════════════════════════════════════
// 威胁排序
// ═══════════════════════════════════════════════
// ═══════════════════════════════════════
[Fact]
public void ThreatPriority_SpeedMatters()
@ -151,12 +61,12 @@ namespace CounterDrone.Core.Tests
var slow = MakeThreat(PowerType.Piston, 60);
slow.Target.TargetType = (int)TargetType.Piston;
slow.ArrivalTime = 100;
slow.ThreatIndex = CalcThreatIndexStatic(slow.Target);
slow.ThreatIndex = CalcIndex(slow.Target);
var fast = MakeThreat(PowerType.Piston, 500);
fast.Target.TargetType = (int)TargetType.Piston;
fast.ArrivalTime = 100;
fast.ThreatIndex = CalcThreatIndexStatic(fast.Target);
fast.ThreatIndex = CalcIndex(fast.Target);
Assert.True(fast.Priority > slow.Priority, $"fast={fast.Priority} slow={slow.Priority}");
}
@ -167,54 +77,106 @@ namespace CounterDrone.Core.Tests
var rotor = MakeThreat(PowerType.Electric, 200);
rotor.Target.TargetType = (int)TargetType.Rotor;
rotor.ArrivalTime = 50;
rotor.ThreatIndex = CalcThreatIndexStatic(rotor.Target);
rotor.ThreatIndex = CalcIndex(rotor.Target);
var jet = MakeThreat(PowerType.Piston, 200);
jet.Target.TargetType = (int)TargetType.HighSpeed;
jet.ArrivalTime = 50;
jet.ThreatIndex = CalcThreatIndexStatic(jet.Target);
jet.ThreatIndex = CalcIndex(jet.Target);
Assert.True(jet.ThreatIndex > rotor.ThreatIndex,
$"jet={jet.ThreatIndex} rotor={rotor.ThreatIndex}");
Assert.True(jet.ThreatIndex > rotor.ThreatIndex, $"jet={jet.ThreatIndex} rotor={rotor.ThreatIndex}");
}
private static float CalcThreatIndexStatic(TargetConfig target)
private static float CalcIndex(TargetConfig t)
{
var typeCoef = new Dictionary<TargetType, float>
var coef = 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;
}.GetValueOrDefault((TargetType)t.TargetType, 1f);
return coef * (float)t.TypicalSpeed / 60f;
}
// ═══════════════════════════════════════════════
// ═══════════════════════════════════════
// 弹药匹配
// ═══════════════════════════════════════════════
// ═══════════════════════════════════════
[Fact] public void Piston_InertGas() { Assert.Equal(AerosolType.InertGas, Plan(PowerType.Piston).Best.Assignments[0].AmmoType); }
[Fact] public void Jet_ActiveMaterial() { Assert.Equal(AerosolType.ActiveMaterial, Plan(PowerType.Jet, 500).Best.Assignments[0].AmmoType); }
[Fact] public void Electric_InertGas() { Assert.Equal(AerosolType.InertGas, Plan(PowerType.Electric).Best.Assignments[0].AmmoType); }
// ═══════════════════════════════════════
// 通道分配
// ═══════════════════════════════════════
[Fact]
public void AmmoMatch_Piston_GetsInertGas()
public void SingleUnit_AllChannelsUsed_IfNeeded()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Piston) },
new CombatScene());
Assert.Equal(AerosolType.InertGas, result.Best.Assignments[0].AmmoType);
var result = Plan(PowerType.Piston, 60);
var events = result.Best.MergedSchedule;
Assert.True(events.Count >= 3, $"count={events.Count}");
// 通道间隔错开,但同轮无 cooldown 级延迟
float first = events.Min(e => e.FireTime);
float last = events.Max(e => e.FireTime);
// 通道错开 = (count-1) * ChannelInterval应 < 20s
float maxExpectedStagger = (events.Count - 1) * 1f + 2f;
Assert.True(last - first < maxExpectedStagger,
$"spread={last-first:F1}s, expected<{maxExpectedStagger:F1}s");
}
[Fact]
public void AmmoMatch_Jet_GetsActiveMaterial()
public void NoThreats_Empty() { Assert.Equal(0, new DefaultDefensePlanner(TestAmmo).Plan(new(), new(), new CombatScene()).Best.ThreatsEngaged); }
[Fact]
public void ChannelInterval_StaggersWithinSalvo()
{
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);
var result = Plan(PowerType.Piston, 60);
var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList();
// 同一单元通道有 ChannelInterval=1s 错开
Assert.True(events.Count >= 2);
}
// ═══════════════════════════════════════════════
// 候选过滤
// ═══════════════════════════════════════════════
[Fact]
public void MultiUnit_PoolsChannels()
{
// 2 个单元 = 32 通道
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(2), new() { MakeThreat(PowerType.Piston, 200) }, new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.True(result.Best.MergedSchedule.Count > 1);
}
[Fact]
public void OneSalvo_NoCooldownNeeded()
{
// 16 通道足够覆盖慢速目标
var result = Plan(PowerType.Piston, 60);
var events = result.Best.MergedSchedule;
float first = events.Min(e => e.FireTime);
float last = events.Max(e => e.FireTime);
// 最后一个事件 - 第一个事件 < Cooldown → 同一轮
Assert.True(last - first < 5f, $"spread={last - first:F1}s, should be < 5s cooldown");
}
[Fact]
public void TotalMunitions_CapsFirepower()
{
var units = new List<FireUnit>
{
new FireUnit { Id = "u0", Type = PlatformType.GroundBased,
Position = new Vector3(5000, 0, 50), GunCount = 4, ChannelsPerGun = 4,
MuzzleVelocity = 800, TotalMunitions = 2, // 只够打 2 发
AmmoTypes = new() { AerosolType.InertGas } },
};
var result = new DefaultDefensePlanner(TestAmmo).Plan(
units, new() { MakeThreat(PowerType.Piston, 200) }, new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.True(result.Best.Assignments.Sum(a => a.RoundsFired) <= 2);
}
// ═══════════════════════════════════════
// 过滤与边界
// ═══════════════════════════════════════
[Fact]
public void IncompatibleAmmo_NotEngaged()
@ -222,172 +184,65 @@ namespace CounterDrone.Core.Tests
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 } },
Position = new Vector3(5000, 0, 50), GunCount = 4, ChannelsPerGun = 4,
MuzzleVelocity = 800, TotalMunitions = 16,
AmmoTypes = new() { AerosolType.ActiveMaterial } },
};
var result = new DefaultDefensePlanner(TestAmmo).Plan(
units,
new List<DroneGroup> { MakeThreat(PowerType.Piston) },
new CombatScene());
units, new() { 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);
}
// ═══════════════════════════════════════════════
// 发射时机
// ═══════════════════════════════════════════════
public void NoUnits_Unengaged() { Assert.Equal(1, Plan(unitCount: 0).Best.ThreatsUnengaged); }
[Fact]
public void FireTime_Positive()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
var result = Plan(PowerType.Piston, 60);
Assert.NotEmpty(result.Best.MergedSchedule);
Assert.All(result.Best.MergedSchedule, fe => Assert.True(fe.FireTime > 0));
}
[Fact]
public void FireTime_BeforeArrivalTime()
public void FireTime_BeforeArrival()
{
var threat = MakeThreat(PowerType.Piston, 120);
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
new List<DroneGroup> { threat },
new CombatScene());
// 所有发射时机应在威胁到达之前
float arrivalTime = threat.GetArrivalTime();
MakeGroundUnits(1), new() { threat }, new CombatScene());
float arrival = threat.GetArrivalTime();
Assert.All(result.Best.MergedSchedule, fe =>
Assert.True(fe.FireTime < arrivalTime,
$"FireTime={fe.FireTime:F0} >= ArrivalTime={arrivalTime:F0}"));
Assert.True(fe.FireTime < arrival, $"FireTime={fe.FireTime:F0} >= {arrival: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());
var threats = new List<DroneGroup> { MakeThreat(PowerType.Piston, 120), MakeThreat(PowerType.Jet, 300) };
threats[0].GroupId = "g0"; threats[1].GroupId = "g1";
var result = new DefaultDefensePlanner(TestAmmo).Plan(MakeGroundUnits(2), threats, new CombatScene());
Assert.Equal(2, result.Best.ThreatsEngaged);
Assert.Equal(0, result.Best.ThreatsUnengaged);
}
[Fact]
public void MultiThreat_LimitedUnits_SomeUnengaged()
public void Critical_HasAssignments()
{
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);
var result = Plan(PowerType.Piston, 120);
Assert.True(result.Critical.Assignments.Count > 0);
Assert.True(result.Critical.OverallProbability >= 0.4f);
}
// ═══════════════════════════════════════════════
// 空基平台
// ═══════════════════════════════════════════════
[Fact]
public void Best_ProbabilityHigherThanCritical()
{
var result = Plan(PowerType.Piston, 120);
Assert.True(result.Best.OverallProbability >= result.Critical.OverallProbability,
$"Best={result.Best.OverallProbability:P0} Critical={result.Critical.OverallProbability:P0}");
}
// ═══════════════════════════════════════
// 空基
// ═══════════════════════════════════════
[Fact]
public void AirBased_Engaged()
@ -395,56 +250,43 @@ namespace CounterDrone.Core.Tests
var units = new List<FireUnit>
{
new FireUnit { Id = "u0", Type = PlatformType.AirBased,
Position = new Vector3(3000, 2500, -2000), CruiseSpeed = 55,
ReleaseAltitude = 1500, TotalMunitions = 3,
Position = new Vector3(3000, 2500, -2000), GunCount = 4, ChannelsPerGun = 4,
CruiseSpeed = 55, ReleaseAltitude = 1500, TotalMunitions = 16,
AmmoTypes = new() { AerosolType.InertGas } },
};
var result = new DefaultDefensePlanner(TestAmmo).Plan(
units,
new List<DroneGroup> { MakeThreat(PowerType.Piston) },
new CombatScene());
var result = new DefaultDefensePlanner(TestAmmo).Plan(units, new() { MakeThreat() }, 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}"));
}
// ═══════════════════════════════════════
// 集成验证
// ═══════════════════════════════════════
[Fact]
public void FullPipeline_Piston_VerifiesRoundAllocation()
{
var units = MakeGroundUnits(8);
var threat = MakeThreat(PowerType.Piston, 200); // 集成测试同参数——速度设为 200
var units = MakeGroundUnits(1); // 1 单元 16 通道
var threat = MakeThreat(PowerType.Piston, 200);
threat.Waypoints.Clear();
threat.Waypoints.Add(new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 });
threat.Waypoints.Add(new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 });
var planner = new DefaultDefensePlanner(TestAmmo);
var result = planner.Plan(units, new() { threat }, new CombatScene { WindSpeed = 3 });
Assert.True(result.Best.ThreatsEngaged == 1);
var result = new DefaultDefensePlanner(TestAmmo).Plan(units, new() { threat }, new CombatScene { WindSpeed = 3 });
Assert.True(result.Best.MergedSchedule.Count >= 8,
$"只有 {result.Best.MergedSchedule.Count} 发,集成测试场景应>=8发");
// 验证 PlatformIndex 有效
$"只有 {result.Best.MergedSchedule.Count} 发,预期 >= 8");
Assert.All(result.Best.MergedSchedule, fe =>
Assert.True(fe.PlatformIndex >= 0 && fe.PlatformIndex < units.Count,
$"PlatformIndex={fe.PlatformIndex} out of [0,{units.Count})"));
Assert.True(fe.PlatformIndex >= 0 && fe.PlatformIndex < units.Count));
}
// ═══════════════════════════════════════
// helpers
// ═══════════════════════════════════════
private PlannerResult Plan(PowerType power = PowerType.Piston, float speed = 120f, int unitCount = 1)
{
return new DefaultDefensePlanner(TestAmmo).Plan(
unitCount > 0 ? MakeGroundUnits(unitCount) : new List<FireUnit>(),
new() { MakeThreat(power, speed) },
new CombatScene());
}
}
}

View File

@ -65,6 +65,26 @@ namespace CounterDrone.Core.Tests
private static readonly string ReportOutputDir = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "reports");
private static EquipmentDeployment MakeEquipment(FireUnitTemplate template, AerosolType ammoType, int quantity, double posX, double posY, double posZ)
{
return new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
PlatformType = template.PlatformType,
Quantity = quantity,
PositionX = posX, PositionY = posY, PositionZ = posZ,
AerosolType = (int)ammoType,
MunitionCount = template.GunCount * template.ChannelsPerGun,
GunCount = template.GunCount,
ChannelsPerGun = template.ChannelsPerGun,
ChannelInterval = template.ChannelInterval,
Cooldown = template.Cooldown,
MuzzleVelocity = template.MuzzleVelocity > 0 ? template.MuzzleVelocity : null,
CruiseSpeed = template.CruiseSpeed > 0 ? template.CruiseSpeed : null,
ReleaseAltitude = template.ReleaseAltitude > 0 ? template.ReleaseAltitude : null,
};
}
private void VerifyAndExportReport(SimulationEngine eng, DroneEntity drone)
{
var detail = _scenario.GetTaskDetail(_taskId)!;
@ -177,14 +197,7 @@ namespace CounterDrone.Core.Tests
});
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
PlatformType = (int)Models.PlatformType.GroundBased,
Quantity = 8, PositionX = 5000, PositionY = 0, PositionZ = 50,
AerosolType = (int)AerosolType.InertGas, MunitionCount = 3,
MuzzleVelocity = 800, Cooldown = 5,
},
MakeEquipment(DefaultFireUnits.GetById("ground-standard"), AerosolType.InertGas, 1, 5000, 0, 50),
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
@ -194,9 +207,9 @@ namespace CounterDrone.Core.Tests
var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var msg = $"Piston: launched={launched} clouds={clouds} status={drone.Status} hp={drone.Hp:F2}";
VerifyAndExportReport(eng, drone);
Assert.True(launched > 0, msg);
Assert.Equal(DroneStatus.Destroyed, drone.Status);
VerifyAndExportReport(eng, drone);
}
// ═══════════════════════════════════════════════
@ -228,14 +241,7 @@ namespace CounterDrone.Core.Tests
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
PlatformType = (int)Models.PlatformType.GroundBased,
Quantity = 8, PositionX = 8000, PositionY = 0, PositionZ = 50,
AerosolType = (int)AerosolType.ActiveMaterial, MunitionCount = 3,
MuzzleVelocity = 800, Cooldown = 5,
},
MakeEquipment(DefaultFireUnits.GetById("ground-standard"), AerosolType.ActiveMaterial, 1, 8000, 0, 50),
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.ActiveMaterial, DisperseHeight = 800 });
@ -245,9 +251,9 @@ namespace CounterDrone.Core.Tests
var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var msg = $"Jet: launched={launched} clouds={clouds} status={drone.Status} hp={drone.Hp:F2}";
VerifyAndExportReport(eng, drone);
Assert.True(launched > 0, msg);
Assert.Equal(DroneStatus.Destroyed, drone.Status);
VerifyAndExportReport(eng, drone);
}
// ═══════════════════════════════════════════════
// 场景 5空基平台 — 基于推荐方案,平台改为空基
@ -279,14 +285,7 @@ namespace CounterDrone.Core.Tests
// 部署空基平台单元
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
PlatformType = (int)Models.PlatformType.AirBased,
Quantity = 8, PositionX = 5000, PositionY = 2500, PositionZ = -2000,
AerosolType = (int)AerosolType.InertGas, MunitionCount = 3,
CruiseSpeed = 55, ReleaseAltitude = 1500, Cooldown = 5,
},
MakeEquipment(DefaultFireUnits.GetById("air-standard"), AerosolType.InertGas, 2, 5000, 2500, -2000),
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
@ -296,9 +295,9 @@ namespace CounterDrone.Core.Tests
var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var msg = $"AirBased: launched={launched} clouds={clouds} status={drone.Status} hp={drone.Hp:F2}";
VerifyAndExportReport(eng, drone);
Assert.True(launched > 0, msg);
Assert.Equal(DroneStatus.Destroyed, drone.Status);
VerifyAndExportReport(eng, drone);
}
}
}