CounterDroneBackend/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs
tian 008df4a86c fix: planner formation lane offset + add 3-drone integration test
Bug: planner used symmetric lane offset (Z=-50/0/+50) but DroneEntity uses start-anchored (Z=0/50/100). 3rd drone at Z=100 got no clouds.

Fix: planner laneOffset = yLane * laneSpacing (matches DroneEntity formationIndex * lateralSpacing).

Test: Scenario_3DronesFormation - 3 drones in Formation (Z=0/50/100), 3 fire units, all destroyed. 192 tests pass.
2026-06-14 21:58:05 +08:00

543 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CounterDrone.Core;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using CounterDrone.Core.Repository;
using CounterDrone.Core.Services;
using CounterDrone.Core.Simulation;
using SQLite;
using Xunit;
namespace CounterDrone.Core.Tests
{
public class FullPipelineTests : IDisposable
{
private readonly string _testDir;
private readonly IPathProvider _paths;
private readonly IScenarioService _scenario;
private readonly FrameDataStore _frameStore;
private readonly SQLiteConnection _mainDb;
private List<AmmunitionSpec> _ammoCatalog;
private string _taskId = string.Empty;
public FullPipelineTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"cd_full_{Guid.NewGuid():N}");
_paths = new TestPathProvider(_testDir);
var dbm = new DatabaseManager(_paths);
_mainDb = dbm.OpenMainDb();
foreach (var a in DefaultAmmunition.GetAll()) _mainDb.Insert(a);
_ammoCatalog = new List<AmmunitionSpec>(DefaultAmmunition.GetAll());
_scenario = new ScenarioService(
new SimTaskRepository(_mainDb), new CombatSceneRepository(_mainDb),
new ControlZoneRepository(_mainDb), new TargetConfigRepository(_mainDb),
new EquipmentDeploymentRepository(_mainDb), new CloudDispersalRepository(_mainDb),
new RoutePlanRepository(_mainDb), new WaypointRepository(_mainDb));
_frameStore = new FrameDataStore(_paths);
}
public void Dispose()
{
_mainDb?.Close();
if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true);
}
private SimulationEngine RunSimulation(int maxTicks, float tickDt = 1f / 20f, float timeScale = 8f)
{
var engine = new SimulationEngine(_scenario, _frameStore, new DamageModelRouter(), _paths, new DefensePlanner(_ammoCatalog, TestPlannerConfig.Instance));
engine.Initialize(_taskId);
engine.TimeScale = timeScale; // 8倍速加速
for (int i = 0; i < maxTicks; i++)
{
var r = engine.Tick(tickDt);
if (r.State == SimulationState.Completed) break;
}
engine.Stop();
return engine;
}
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)!;
var report = new ReportGenerator().Generate(detail, eng.Events.ToList(), drone.Status, eng.SimulationTime);
Assert.Contains("对抗结果", report);
Assert.Contains(detail.Task.Name, report);
var svc = new ReportService(_mainDb, _paths);
var r = svc.Generate(_taskId, detail, eng.Events.ToList(), drone.Status.ToString(), eng.SimulationTime);
svc.ExportToFile(r.Id, ReportOutputDir); // 持久化目录
}
// ═══════════════════════════════════════════════
// 场景 1无防御 — 无人机到达目标
// ═══════════════════════════════════════════════
[Fact]
public void Scenario_NoDefense_DroneReachesTarget()
{
var task = _scenario.CreateTask("无防御测试", "");
_taskId = task.Id;
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig
{
GroupId = "default",
TargetType = (int)TargetType.FixedWing,
PowerType = (int)PowerType.Jet,
Quantity = 1,
TypicalSpeed = 600,
TypicalAltitude = 400,
});
_scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 400, PosZ = 0, Speed = 600 },
new Waypoint { PosX = 3000, PosY = 400, PosZ = 0, Speed = 600 },
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal());
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>());
var eng = RunSimulation(400);
Assert.Equal(DroneStatus.ReachedTarget, eng.Drones[0].Status);
}
// ═══════════════════════════════════════════════
// 场景 2管控区域侵入
// ═══════════════════════════════════════════════
[Fact]
public void Scenario_ZoneIntrusion_DroneMarked()
{
var task = _scenario.CreateTask("管控区侵入测试", "");
_taskId = task.Id;
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig
{
GroupId = "default",
TargetType = (int)TargetType.Electric,
PowerType = (int)PowerType.Electric,
Quantity = 1,
TypicalSpeed = 300,
TypicalAltitude = 300,
});
_scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 300, PosZ = 0, Speed = 300 },
new Waypoint { PosX = 3000, PosY = 300, PosZ = 0, Speed = 300 },
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal());
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>());
_scenario.SaveControlZones(_taskId, new List<Models.ControlZone>
{
new Models.ControlZone
{
Name = "禁飞区",
VerticesJson = "[{\"X\":1200,\"Y\":0,\"Z\":-200},{\"X\":1800,\"Y\":0,\"Z\":-200},{\"X\":1800,\"Y\":0,\"Z\":200},{\"X\":1200,\"Y\":0,\"Z\":200}]",
MinAltitude = 0, MaxAltitude = 1000,
},
});
var eng = RunSimulation(400);
Assert.Equal(DroneStatus.ZoneIntruded, eng.Drones[0].Status);
}
// ═══════════════════════════════════════════════
// 场景 3活塞式 200km/h惰性气体拦截
// ═══════════════════════════════════════════════
[Fact]
public void Scenario_Piston_InertGasIntercept()
{
var task = _scenario.CreateTask("拦截活塞式无人机", "");
_taskId = task.Id;
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig
{
GroupId = "default", TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston,
Quantity = 1,
TypicalSpeed = 200, TypicalAltitude = 500,
});
_scenario.SaveRoute(_taskId, "default", new RoutePlan
{
FormationMode = (int)FormationMode.Formation,
LateralSpacing = 50,
LateralCount = 1,
LongitudinalCount = 1,
},
new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 150 },
new Waypoint { PosX = 5000, PosY = 500, PosZ = 0, Speed = 150 },
});
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
MakeEquipment(DefaultFireUnits.GetById("ground-light"), AerosolType.InertGas, 1, 1500, 0, 50),
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
var eng = RunSimulation(8000);
var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var dump = new System.Text.StringBuilder();
// 1. Planner 期望(来自 planner_targets.csv
dump.AppendLine("=== 1. Planner 云团期望 (见 planner_targets.csv) ===");
// 2. 弹药到位
dump.AppendLine("=== 2. 弹药实际到位 ===");
dump.AppendLine("id,arrivalTime,posX,posY,posZ,cloudId,radius,density,elapsed");
foreach (var c in eng.Clouds)
dump.AppendLine($"cloud,{c.CreatedAt:F3},{c.Dispersion.Center.X:F1},{c.Dispersion.Center.Y:F1},{c.Dispersion.Center.Z:F1},{c.Id},{c.Dispersion.EffectiveRadius:F2},{c.Dispersion.CoreDensity:F6},{c.Dispersion.Elapsed:F1}");
// 3. 云团最终状态
dump.AppendLine("=== 3. 云团状态(仿真结束) ===");
dump.AppendLine("cloudId,radius,density,elapsed,dissipated");
foreach (var c in eng.Clouds)
dump.AppendLine($"{c.Id},{c.Dispersion.EffectiveRadius:F2},{c.Dispersion.CoreDensity:F6},{c.Dispersion.Elapsed:F1},{c.IsDissipated}");
// 4. 无人机状态
var drone = eng.Drones[0];
dump.AppendLine("=== 4. 无人机 ===");
dump.AppendLine($"status={drone.Status} hp={drone.Hp:F3} exposureTime={drone.ExposureTime:F3}");
dump.AppendLine($"speed={drone.TypicalSpeed}km/h startPos=({drone.PosX:F1},{drone.PosY:F1},{drone.PosZ:F1})");
System.IO.File.WriteAllText(@"C:\Users\Tellme\Desktop\piston_analysis.txt", dump.ToString());
var times = eng.Events.Where(e => e.Type == SimEventType.MunitionLaunched).Select(e => e.OccurredAt).OrderBy(t => t).ToList();
var timeInfo = $"times=[{times.First():F2}..{times.Last():F2}] span={times.Last()-times.First():F2}s";
var msg = $"Piston: launched={launched} clouds={clouds} drones={eng.Drones.Count} {timeInfo}";
foreach (var d in eng.Drones)
msg += $" [{d.Status} hp={d.Hp:F2}]";
VerifyAndExportReport(eng, eng.Drones[0]);
System.IO.File.WriteAllText(@"C:\Users\Tellme\Desktop\test_piston.txt", msg);
Assert.True(launched > 0, msg);
Assert.True(eng.Drones.All(d => d.Status == DroneStatus.Destroyed), msg);
}
// ═══════════════════════════════════════════════
// 场景 3b活塞式 + 西风 5m/s — 验证风偏补偿后仍能拦截
// 航路 X:0→5000 Z:0西风 WindToVector(W)=(-5,0,0) 把云团吹向 -X
// Planner 应逆风预置抛撒点(+X方向云团漂移 ~150m 后回到航路
// ═══════════════════════════════════════════════
[Fact]
public void Scenario_Piston_Windy_InertGasIntercept()
{
var task = _scenario.CreateTask("活塞+西风拦截测试", "");
_taskId = task.Id;
_scenario.SaveScene(_taskId, new CombatScene
{
WeatherType = (int)WeatherType.Sunny,
WindSpeed = 5,
WindDirection = (int)WindDirection.W,
});
_scenario.SaveTarget(_taskId, new TargetConfig
{
GroupId = "default", TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston,
Quantity = 1,
TypicalSpeed = 200, TypicalAltitude = 500,
});
_scenario.SaveRoute(_taskId, "default", new RoutePlan
{
FormationMode = (int)FormationMode.Formation,
LateralSpacing = 50,
LateralCount = 1,
LongitudinalCount = 1,
},
new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 150 },
new Waypoint { PosX = 5000, PosY = 500, PosZ = 0, Speed = 150 },
});
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
MakeEquipment(DefaultFireUnits.GetById("ground-light"), AerosolType.InertGas, 1, 1500, 0, 50),
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
var eng = RunSimulation(8000);
var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var dump = new System.Text.StringBuilder();
dump.AppendLine("=== 活塞+西风5m/s ===");
dump.AppendLine("cloudCreatedAt,centerX,centerY,centerZ,radius,density,elapsed");
foreach (var c in eng.Clouds)
dump.AppendLine($"{c.CreatedAt:F3},{c.Dispersion.Center.X:F1},{c.Dispersion.Center.Y:F1},{c.Dispersion.Center.Z:F1},{c.Dispersion.EffectiveRadius:F2},{c.Dispersion.CoreDensity:F6},{c.Dispersion.Elapsed:F1}");
dump.AppendLine("=== 无人机 ===");
var drone = eng.Drones[0];
dump.AppendLine($"status={drone.Status} hp={drone.Hp:F3} exposureTime={drone.ExposureTime:F3}");
dump.AppendLine($"endPos=({drone.PosX:F1},{drone.PosY:F1},{drone.PosZ:F1})");
System.IO.File.WriteAllText(@"C:\Users\Tellme\Desktop\piston_windy.txt", dump.ToString());
var msg = $"PistonWindy: launched={launched} clouds={clouds}";
foreach (var d in eng.Drones)
msg += $" [{d.Status} hp={d.Hp:F2}]";
Assert.True(launched > 0, msg);
Assert.True(eng.Drones.All(d => d.Status == DroneStatus.Destroyed), msg);
VerifyAndExportReport(eng, eng.Drones[0]);
}
// ═══════════════════════════════════════════════
// 场景 4喷气发动机 → 算法推荐活性材料
// ═══════════════════════════════════════════════
[Fact]
public void Scenario_Jet_ActiveMaterialIntercept()
{
var task = _scenario.CreateTask("拦截喷气式无人机", "");
_taskId = task.Id;
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig
{
GroupId = "default",
TargetType = (int)TargetType.HighSpeed,
PowerType = (int)PowerType.Jet,
Quantity = 1,
TypicalSpeed = 200,
TypicalAltitude = 800,
});
_scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 800, PosZ = 0, Speed = 200 },
new Waypoint { PosX = 5000, PosY = 800, PosZ = 0, Speed = 200 },
});
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
MakeEquipment(DefaultFireUnits.GetById("ground-standard"), AerosolType.ActiveMaterial, 1, 1500, 0, 50),
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.ActiveMaterial, DisperseHeight = 800 });
var eng = RunSimulation(8000);
var drone = eng.Drones[0];
var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var msg = $"Jet: launched={launched} clouds={clouds} status={drone.Status} hp={drone.Hp:F2}";
System.IO.File.WriteAllText(@"C:\Users\Tellme\Desktop\test_jet.txt", msg);
VerifyAndExportReport(eng, drone);
Assert.True(launched > 0, msg);
Assert.Equal(DroneStatus.Destroyed, drone.Status);
}
// ═══════════════════════════════════════════════
// 场景 5空基平台 — 基于推荐方案,平台改为空基
// ═══════════════════════════════════════════════
[Fact]
public void Scenario_AirBased_PlatformFliesAndDrops()
{
var task = _scenario.CreateTask("空基平台拦截测试", "");
_taskId = task.Id;
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig
{
GroupId = "default",
TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston,
Quantity = 1,
TypicalSpeed = 150,
TypicalAltitude = 500,
});
_scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 150 },
new Waypoint { PosX = 10000, PosY = 500, PosZ = 0, Speed = 150 },
});
// 部署空基平台单元
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
MakeEquipment(DefaultFireUnits.GetById("air-standard"), AerosolType.InertGas, 3, 1500, 1000, 0),
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
// 验证:空基 PlatformType 正确持久化
var savedAir = _scenario.GetTaskDetail(_taskId)!;
Assert.All(savedAir.Equipment, eq => Assert.Equal((int?)0, eq.PlatformType));
var eng = RunSimulation(8000);
// 诊断:空基发射事件应有"空基"描述
Assert.All(eng.Events.Where(e => e.Type == SimEventType.MunitionLaunched),
e => Assert.Contains("空基", e.Description));
// 验证引擎内 PlatformType
Assert.NotEmpty(eng.Events);
Assert.All(eng.Events.Where(e => e.Type == SimEventType.MunitionLaunched),
e => Assert.Contains("空基", e.Description));
var drone = eng.Drones[0];
var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var msg = $"AirBased: launched={launched} clouds={clouds} status={drone.Status} hp={drone.Hp:F2}";
System.IO.File.WriteAllText(@"C:\Users\Tellme\Desktop\test_air.txt", msg);
VerifyAndExportReport(eng, drone);
Assert.True(launched > 0, msg);
Assert.True(drone.Hp < 0.1f, msg);
}
// ═══════════════════════════════════════════════
// 场景 5b空基平台 + 东风 5m/s — 验证风偏补偿后仍能拦截
// 航路 X:0→10000 Z:0东风 WindToVector(E)=(5,0,0) 把云团吹向 +X
// Planner 应逆风预置抛撒点(-X方向空基平台投放后云团漂移回航路
// ═══════════════════════════════════════════════
[Fact]
public void Scenario_AirBased_Windy_PlatformFliesAndDrops()
{
var task = _scenario.CreateTask("空基+东风拦截测试", "");
_taskId = task.Id;
_scenario.SaveScene(_taskId, new CombatScene
{
WeatherType = (int)WeatherType.Sunny,
WindSpeed = 5,
WindDirection = (int)WindDirection.E,
});
_scenario.SaveTarget(_taskId, new TargetConfig
{
GroupId = "default",
TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston,
Quantity = 1,
TypicalSpeed = 150,
TypicalAltitude = 500,
});
_scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 150 },
new Waypoint { PosX = 10000, PosY = 500, PosZ = 0, Speed = 150 },
});
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
MakeEquipment(DefaultFireUnits.GetById("air-standard"), AerosolType.InertGas, 3, 1500, 1000, 0),
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
var eng = RunSimulation(8000);
Assert.All(eng.Events.Where(e => e.Type == SimEventType.MunitionLaunched),
e => Assert.Contains("空基", e.Description));
var drone = eng.Drones[0];
var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var dump = new System.Text.StringBuilder();
dump.AppendLine("=== 空基+东风5m/s ===");
dump.AppendLine("cloudCreatedAt,centerX,centerY,centerZ,radius,density,elapsed");
foreach (var c in eng.Clouds)
dump.AppendLine($"{c.CreatedAt:F3},{c.Dispersion.Center.X:F1},{c.Dispersion.Center.Y:F1},{c.Dispersion.Center.Z:F1},{c.Dispersion.EffectiveRadius:F2},{c.Dispersion.CoreDensity:F6},{c.Dispersion.Elapsed:F1}");
dump.AppendLine("=== 无人机 ===");
dump.AppendLine($"status={drone.Status} hp={drone.Hp:F3} exposureTime={drone.ExposureTime:F3}");
dump.AppendLine($"endPos=({drone.PosX:F1},{drone.PosY:F1},{drone.PosZ:F1})");
System.IO.File.WriteAllText(@"C:\Users\Tellme\Desktop\air_windy.txt", dump.ToString());
var msg = $"AirBasedWindy: launched={launched} clouds={clouds} status={drone.Status} hp={drone.Hp:F2}";
Assert.True(launched > 0, msg);
Assert.True(drone.Hp < 0.1f, msg);
VerifyAndExportReport(eng, drone);
}
// ═══════════════════════════════════════════════
// 场景 63 架活塞编队(横排 Z=0/50/100— 多无人机端到端
// 验证 planner 给每个车道分配云团、3 架都被击毁
// ═══════════════════════════════════════════════
[Fact]
public void Scenario_3DronesFormation_AllDestroyed()
{
var task = _scenario.CreateTask("3架编队拦截测试", "");
_taskId = task.Id;
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig
{
GroupId = "default", TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston,
Quantity = 3,
TypicalSpeed = 200, TypicalAltitude = 500,
});
// 3 架横排编队Formation 模式,横向间距 50m
// 引擎会生成 3 架无人机Z 偏移 0/50/100latIdx × LateralSpacing
_scenario.SaveRoute(_taskId, "default", new RoutePlan
{
FormationMode = (int)FormationMode.Formation,
LateralSpacing = 50,
LateralCount = 3,
LongitudinalCount = 1,
},
new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 },
new Waypoint { PosX = 10000, PosY = 500, PosZ = 0, Speed = 200 },
});
// 3 个火力单元部署在中点附近X=4500每个单元 i*50 偏移 → 4500/4550/4600
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
MakeEquipment(DefaultFireUnits.GetById("ground-light"), AerosolType.InertGas, 3, 4500, 0, 50),
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
var eng = RunSimulation(8000);
var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var destroyed = eng.Events.Count(e => e.Type == SimEventType.DroneDestroyed);
// 验证3 架无人机都被击毁
var msg = $"3Drones: launched={launched} clouds={clouds} destroyed={destroyed} drones={eng.Drones.Count}";
foreach (var d in eng.Drones)
msg += $" [{d.Status} hp={d.Hp:F2} pos=({d.PosX:F0},{d.PosZ:F0})]";
VerifyAndExportReport(eng, eng.Drones[0]);
Assert.Equal(3, eng.Drones.Count);
Assert.True(launched > 0, msg);
Assert.True(eng.Drones.All(d => d.Status == DroneStatus.Destroyed), msg);
}
}
}