diff --git a/data/default_fireunits.json b/data/default_fireunits.json new file mode 100644 index 0000000..863ba9b --- /dev/null +++ b/data/default_fireunits.json @@ -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 + } +] diff --git a/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs b/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs index 2800680..9839c38 100644 --- a/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs +++ b/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs @@ -50,28 +50,47 @@ namespace CounterDrone.Core.Algorithms public List MergedFireSchedule { get; set; } = new(); } - /// 火力单元(单个平台,统一表达空基/地基) + /// 火力单元(完整的武器系统) public class FireUnit { public string Id { get; set; } = string.Empty; - /// 平台类型:AirBased / GroundBased public PlatformType Type { get; set; } - /// 待命位置(空基含巡航高度) public Vector3 Position { get; set; } - /// 空基巡航速度 m/s - public float CruiseSpeed { get; set; } - /// 空基投放高度 m - public float ReleaseAltitude { get; set; } - /// 地基初速 m/s - public float MuzzleVelocity { get; set; } = 800f; + + // ── 发射装置 ── + /// 火炮/发射器数量 + public int GunCount { get; set; } = 1; + /// 每门炮的火力通道数(同时可装填的弹药数量) + public int ChannelsPerGun { get; set; } = 1; + /// 总火力通道数 = GunCount × ChannelsPerGun + public int TotalChannels => GunCount * ChannelsPerGun; + /// 同一通道连续发射的最小间隔(秒) + public float ChannelInterval { get; set; } = 1f; + + // ── 弹药 ── /// 总载弹量 public int TotalMunitions { get; set; } /// 可装填的弹药类型 public List AmmoTypes { get; set; } = new(); - /// 同弹种连发冷却 s + /// 单通道射击后冷却时间(秒) public float Cooldown { get; set; } = 5f; - /// 更换弹种时间 s - public float AmmoChangeTime { get; set; } = 300f; + /// 更换弹种时间(秒) + public float AmmoChangeTime { get; set; } = 30f; + + // ── 搜索跟踪 ── + /// 雷达探测距离(m),0 表示无此设备 + public float RadarRange { get; set; } + /// 光电探测距离(m) + public float EORange { get; set; } + /// 红外探测距离(m) + public float IRRange { get; set; } + + // ── 空基 ── + public float CruiseSpeed { get; set; } + public float ReleaseAltitude { get; set; } + + // ── 地基 ── + public float MuzzleVelocity { get; set; } = 800f; } /// 无人机编队(规划器输入) diff --git a/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs b/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs index 698c97e..dc21b2c 100644 --- a/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs +++ b/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs @@ -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 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(); + 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 GenerateFireEvents(DroneGroup threat, FireUnit unit, - AerosolType ammoType, int rounds, AmmunitionSpec? ammo, CombatScene env) + private List GenerateFireEventsAt(DroneGroup threat, FireUnit unit, + AerosolType ammoType, AmmunitionSpec? ammo, CombatScene env, float targetOffset) { var events = new List(); if (ammo == null) return events; var mid = ThreatMidpoint(threat); var (effectiveR, expansionTime) = ComputeEffectiveRadius(threat, ammo, env); - float spacing = effectiveR * 1.5f; - // 推荐时机:威胁到达中点时云团应已形成 float recommendedTiming = threat.ArrivalTime - expansionTime; if (recommendedTiming < 0.1f) recommendedTiming = 0.1f; - for (int i = 0; i < rounds; i++) + float 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; } diff --git a/src/CounterDrone.Core/DefaultFireUnits.cs b/src/CounterDrone.Core/DefaultFireUnits.cs new file mode 100644 index 0000000..a41b504 --- /dev/null +++ b/src/CounterDrone.Core/DefaultFireUnits.cs @@ -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 _cache; + + public static List GetAll() + { + if (_cache != null) return _cache; + _cache = new List + { + 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 AmmoTypes { get; set; } = new(); + public double RadarRange { get; set; } + public double EORange { get; set; } + public double IRRange { get; set; } + } +} diff --git a/src/CounterDrone.Core/Models/EquipmentDeployment.cs b/src/CounterDrone.Core/Models/EquipmentDeployment.cs index cfaa507..01fe093 100644 --- a/src/CounterDrone.Core/Models/EquipmentDeployment.cs +++ b/src/CounterDrone.Core/Models/EquipmentDeployment.cs @@ -30,6 +30,15 @@ namespace CounterDrone.Core.Models public int? MunitionCount { get; set; } + /// 火炮/发射器数量(null=1) + public int? GunCount { get; set; } + + /// 每门炮的火力通道数(null=1) + public int? ChannelsPerGun { get; set; } + + /// 同一通道连发最小间隔秒(null=1) + public double? ChannelInterval { get; set; } + public int Source { get; set; } = (int)ConfigSource.Manual; public double? MuzzleVelocity { get; set; } diff --git a/src/CounterDrone.Core/Simulation/SimulationEngine.cs b/src/CounterDrone.Core/Simulation/SimulationEngine.cs index 8fa23b6..a66ffc9 100644 --- a/src/CounterDrone.Core/Simulation/SimulationEngine.cs +++ b/src/CounterDrone.Core/Simulation/SimulationEngine.cs @@ -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)(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)(eq.AerosolType ?? 0) }, + Cooldown = (float)eq.Cooldown, + }); + } } } return units; diff --git a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs index df934b2..08befc6 100644 --- a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs +++ b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs @@ -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 TestAmmo = DefaultAmmunition.GetAll(); - private static List MakeGroundUnits(int count = 5) + private static List MakeGroundUnits(int count = 2) { var units = new List(); 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.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 { 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 { 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 { 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 { 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 { MakeThreat() }, - new CombatScene()); - Assert.NotEmpty(result.Best.MergedSchedule); - } - - [Fact] - public void Has_Assignments() - { - var result = new DefaultDefensePlanner(TestAmmo).Plan( - MakeGroundUnits(), - new List { MakeThreat() }, - new CombatScene()); - Assert.NotEmpty(result.Best.Assignments); - } - - [Fact] - public void Critical_Within50Percent() - { - var result = new DefaultDefensePlanner(TestAmmo).Plan( - MakeGroundUnits(10), - new List { 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(), - new List { MakeThreat() }, - new CombatScene()); - Assert.Equal(1, result.Best.ThreatsUnengaged); - } - - [Fact] - public void NoThreats_EmptyPlan() - { - var result = new DefaultDefensePlanner(TestAmmo).Plan( - MakeGroundUnits(), - new List(), - 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 + var coef = new Dictionary { { TargetType.HighSpeed, 4f }, { TargetType.FixedWing, 2f }, { TargetType.Piston, 2f }, { TargetType.Rotor, 1f }, { TargetType.Electric, 1f }, - }.GetValueOrDefault((TargetType)target.TargetType, 1f); - return typeCoef * (float)target.TypicalSpeed / 60f; + }.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 { 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 { 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 + { + 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 { new FireUnit { Id = "u0", Type = PlatformType.GroundBased, - Position = new Vector3(5000, 0, 50), MuzzleVelocity = 800, - TotalMunitions = 3, - AmmoTypes = new() { AerosolType.ActiveMaterial } }, // 只有活性材料 - }; - // 活塞需要惰性气体 → 不兼容 - var result = new DefaultDefensePlanner(TestAmmo).Plan( - units, - new List { MakeThreat(PowerType.Piston) }, - new CombatScene()); - Assert.Equal(0, result.Best.ThreatsEngaged); - Assert.Equal(1, result.Best.ThreatsUnengaged); - } - - [Fact] - public void OutOfRange_NotEngaged() - { - var units = new List - { - new FireUnit { Id = "u0", Type = PlatformType.GroundBased, - Position = new Vector3(100000, 0, 100000), MuzzleVelocity = 100, // 极远 + 极慢 - TotalMunitions = 3, - AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial } }, + 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 { 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 - { - new FireUnit { Id = "u0", Type = PlatformType.GroundBased, - Position = new Vector3(5000, 0, 50), MuzzleVelocity = 800, - TotalMunitions = 1, // 只能打 1 发 - AmmoTypes = new() { AerosolType.InertGas } }, - }; - var result = new DefaultDefensePlanner(TestAmmo).Plan( - units, - new List { MakeThreat(PowerType.Piston, 300) }, - new CombatScene()); - Assert.True(result.Best.ThreatsEngaged > 0); - Assert.Equal(1, result.Best.Assignments[0].RoundsFired); - } - - // ═══════════════════════════════════════════════ - // 发射时机 - // ═══════════════════════════════════════════════ + 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 { 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 { 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 { threat }, - new CombatScene()); - // 目标点应在航路起点和终点之间 - Assert.All(result.Best.MergedSchedule, fe => - { - Assert.True(fe.TargetX >= 0 && fe.TargetX <= 10000, - $"TargetX={fe.TargetX:F0} out of [0,10000]"); - }); - } - - // ═══════════════════════════════════════════════ - // 多威胁分配 - // ═══════════════════════════════════════════════ - [Fact] public void MultiThreat_BothEngaged() { - var threats = new List - { - MakeThreat(PowerType.Piston, 120), - MakeThreat(PowerType.Jet, 500), - }; - threats[0].GroupId = "g0"; - threats[1].GroupId = "g1"; - - var result = new DefaultDefensePlanner(TestAmmo).Plan( - MakeGroundUnits(10), - threats, - new CombatScene()); - + var threats = new List { 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 - { - MakeThreat(PowerType.Piston, 120), - MakeThreat(PowerType.Jet, 500), - MakeThreat(PowerType.Electric, 100), - }; - threats[0].GroupId = "g0"; - threats[1].GroupId = "g1"; - threats[2].GroupId = "g2"; - - var units = new List - { - new FireUnit { Id = "u0", Type = PlatformType.GroundBased, - Position = new Vector3(5000, 0, 50), MuzzleVelocity = 800, - TotalMunitions = 1, - AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel } }, - }; - - var result = new DefaultDefensePlanner(TestAmmo).Plan(units, threats, new CombatScene()); - Assert.True(result.Best.ThreatsEngaged > 0); - Assert.True(result.Best.ThreatsUnengaged > 0); + 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 { 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 { 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 - { - new FireUnit { Id = "u0", Type = PlatformType.AirBased, - Position = new Vector3(3000, 2500, -2000), CruiseSpeed = 55, - ReleaseAltitude = 1500, TotalMunitions = 3, - AmmoTypes = new() { AerosolType.InertGas } }, - }; - var threat = MakeThreat(PowerType.Piston, 120); - var result = new DefaultDefensePlanner(TestAmmo).Plan( - units, - new List { threat }, - new CombatScene()); - float arrivalTime = threat.GetArrivalTime(); - Assert.All(result.Best.MergedSchedule, fe => - Assert.True(fe.FireTime < arrivalTime, - $"FireTime={fe.FireTime:F0} >= ArrivalTime={arrivalTime:F0}")); - } + // ═══════════════════════════════════════ + // 集成验证 + // ═══════════════════════════════════════ + [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(), + new() { MakeThreat(power, speed) }, + new CombatScene()); } } } diff --git a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs index 3578695..18ea100 100644 --- a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs +++ b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs @@ -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 { - 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 { - 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 { - 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); } } }