From 2ef16d083c557177aa21780f206ab11042fbbb92 Mon Sep 17 00:00:00 2001
From: tian <11429339@qq.com>
Date: Thu, 11 Jun 2026 15:09:53 +0800
Subject: [PATCH] =?UTF-8?q?Phase=204=20=E9=87=8D=E6=9E=84:=20=E4=BB=BF?=
=?UTF-8?q?=E7=9C=9F=E5=BC=95=E6=93=8E=E6=94=B9=E4=B8=BA=E7=BA=AF=E6=89=A7?=
=?UTF-8?q?=E8=A1=8C=E5=99=A8=20+=20Kinematics=E5=B7=A5=E5=85=B7=E7=B1=BB?=
=?UTF-8?q?=20+=204=E4=B8=AA=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=9C=BA=E6=99=AF(?=
=?UTF-8?q?2=E4=B8=AA=E9=80=9A=E8=BF=87)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Algorithms/GaussianPuffDispersion.cs | 26 +-
.../Algorithms/Kinematics.cs | 105 ++++++
.../Simulation/ControlZone.cs | 24 +-
.../Simulation/DroneEntity.cs | 38 +-
.../Simulation/MunitionEntity.cs | 26 +-
src/CounterDrone.Core/Simulation/SimTypes.cs | 27 +-
.../Simulation/SimulationEngine.cs | 328 +++++++----------
.../ControlZoneEntityTests.cs | 43 +++
.../DefenseAdvisorRealityTests.cs | 72 ++++
.../DroneEntityTests.cs | 111 ++++++
.../FullPipelineTests.cs | 330 ++++++++++++++++++
.../KinematicsTests.cs | 65 ++++
.../MunitionEntityTests.cs | 58 +++
13 files changed, 945 insertions(+), 308 deletions(-)
create mode 100644 src/CounterDrone.Core/Algorithms/Kinematics.cs
create mode 100644 test/unit/CounterDrone.Core.Tests/ControlZoneEntityTests.cs
create mode 100644 test/unit/CounterDrone.Core.Tests/DefenseAdvisorRealityTests.cs
create mode 100644 test/unit/CounterDrone.Core.Tests/DroneEntityTests.cs
create mode 100644 test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs
create mode 100644 test/unit/CounterDrone.Core.Tests/KinematicsTests.cs
create mode 100644 test/unit/CounterDrone.Core.Tests/MunitionEntityTests.cs
diff --git a/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs b/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs
index f45d7e8..e5cace8 100644
--- a/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs
+++ b/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs
@@ -29,8 +29,8 @@ namespace CounterDrone.Core.Algorithms
IsDissipated = false;
// 风速 → 速度矢量
- var dir = WindToVector((WindDirection)env.WindDirection);
- _windVelocity = dir * (float)env.WindSpeed;
+ var (vx, vy, vz) = Kinematics.WindToVector((WindDirection)env.WindDirection, (float)env.WindSpeed);
+ _windVelocity = new Algorithms.Vector3(vx, vy, vz);
}
public void Tick(float deltaTime, float windSpeed, WindDirection windDir)
@@ -40,8 +40,8 @@ namespace CounterDrone.Core.Algorithms
_elapsed += deltaTime;
// 更新风速(允许动态变化)
- var dir = WindToVector(windDir);
- _windVelocity = dir * windSpeed;
+ var (vx, vy, vz) = Kinematics.WindToVector(windDir, windSpeed);
+ _windVelocity = new Algorithms.Vector3(vx, vy, vz);
// 半径膨胀:初始半径 + 扩散速率 × 时间
// 加入大气稳定度因子(简化:1.0)
@@ -69,23 +69,5 @@ namespace CounterDrone.Core.Algorithms
Particles.Opacity = 0f;
}
}
-
- private static Vector3 WindToVector(WindDirection dir)
- {
- var angle = dir switch
- {
- WindDirection.N => 0f,
- WindDirection.NE => 45f,
- WindDirection.E => 90f,
- WindDirection.SE => 135f,
- WindDirection.S => 180f,
- WindDirection.SW => 225f,
- WindDirection.W => 270f,
- WindDirection.NW => 315f,
- _ => 0f,
- };
- var rad = angle * (float)System.Math.PI / 180f;
- return new Vector3((float)System.Math.Sin(rad), 0, (float)System.Math.Cos(rad));
- }
}
}
diff --git a/src/CounterDrone.Core/Algorithms/Kinematics.cs b/src/CounterDrone.Core/Algorithms/Kinematics.cs
new file mode 100644
index 0000000..961571c
--- /dev/null
+++ b/src/CounterDrone.Core/Algorithms/Kinematics.cs
@@ -0,0 +1,105 @@
+using System;
+using CounterDrone.Core.Models;
+
+namespace CounterDrone.Core.Algorithms
+{
+ /// 通用运动学 / 几何工具
+ public static class Kinematics
+ {
+ /// 风向 → 单位速度矢量 (X, 0, Z),右手系 Y-up
+ public static (float X, float Y, float Z) WindToVector(WindDirection dir, float speed)
+ {
+ var angle = dir switch
+ {
+ WindDirection.N => 0f,
+ WindDirection.NE => 45f,
+ WindDirection.E => 90f,
+ WindDirection.SE => 135f,
+ WindDirection.S => 180f,
+ WindDirection.SW => 225f,
+ WindDirection.W => 270f,
+ WindDirection.NW => 315f,
+ _ => 0f,
+ };
+ var rad = angle * (float)Math.PI / 180f;
+ return ((float)Math.Sin(rad) * speed, 0, (float)Math.Cos(rad) * speed);
+ }
+
+ /// 计算抛物线炮弹的发射角
+ /// 水平距离 (m)
+ /// 初速 (m/s)
+ /// 释放高度 (m),弹道顶点必须 ≥ 此值
+ /// 发射角 (rad),取低弹道
+ public static float CalculateLaunchAngle(float range, float muzzleVelocity, float releaseAltitude)
+ {
+ var v2 = muzzleVelocity * muzzleVelocity;
+ var g = 9.81f;
+
+ // 1. 射程所需角:θr = arcsin(R*g/v²) / 2
+ var rangeRatio = range * g / v2;
+ var angleRange = rangeRatio >= 1.0f
+ ? 45f * (float)Math.PI / 180f
+ : (float)Math.Asin(rangeRatio) / 2f;
+
+ // 2. 释放高度所需最小角:H = v²*sin²(θ)/(2g) → sin(θ) = √(2gH)/v
+ var sinMinHeight = (float)Math.Sqrt(2f * g * releaseAltitude * 1.05f) / muzzleVelocity;
+ var angleHeight = sinMinHeight >= 1.0f
+ ? 90f * (float)Math.PI / 180f
+ : (float)Math.Asin(sinMinHeight);
+
+ return Math.Max(angleRange, angleHeight);
+ }
+
+ /// 根据发射参数计算抛物线位置
+ public static (float X, float Y, float Z) ParabolicPosition(
+ float startX, float startY, float startZ,
+ float launchAngle, float azimuth,
+ float muzzleVelocity, float time)
+ {
+ var cosA = (float)Math.Cos(launchAngle);
+ var sinA = (float)Math.Sin(launchAngle);
+ var g = 9.81f;
+
+ var horizontalDist = muzzleVelocity * cosA * time;
+ var x = startX + horizontalDist * (float)Math.Sin(azimuth);
+ var z = startZ + horizontalDist * (float)Math.Cos(azimuth);
+ var y = startY + muzzleVelocity * sinA * time - 0.5f * g * time * time;
+
+ return (x, y, z);
+ }
+
+ /// 点到矩形距离(简化判定)
+ public static float Distance2D(float x1, float z1, float x2, float z2)
+ {
+ var dx = x2 - x1;
+ var dz = z2 - z1;
+ return (float)Math.Sqrt(dx * dx + dz * dz);
+ }
+
+ /// 点到三维点距离
+ public static float Distance3D(float x1, float y1, float z1, float x2, float y2, float z2)
+ {
+ var dx = x2 - x1;
+ var dy = y2 - y1;
+ var dz = z2 - z1;
+ return (float)Math.Sqrt(dx * dx + dy * dy + dz * dz);
+ }
+
+ /// 射线法 — 点是否在水平多边形内
+ public static bool PointInPolygon(float px, float pz, ReadOnlySpan<(float X, float Z)> vertices)
+ {
+ if (vertices.Length < 3) return false;
+ bool inside = false;
+ int j = vertices.Length - 1;
+ for (int i = 0; i < vertices.Length; i++)
+ {
+ var xi = vertices[i].X; var zi = vertices[i].Z;
+ var xj = vertices[j].X; var zj = vertices[j].Z;
+ if ((zi > pz) != (zj > pz) && px < (xj - xi) * (pz - zi) / (zj - zi) + xi)
+ inside = !inside;
+ j = i;
+ }
+ return inside;
+ }
+ }
+}
diff --git a/src/CounterDrone.Core/Simulation/ControlZone.cs b/src/CounterDrone.Core/Simulation/ControlZone.cs
index 0e90de7..d8e4c7a 100644
--- a/src/CounterDrone.Core/Simulation/ControlZone.cs
+++ b/src/CounterDrone.Core/Simulation/ControlZone.cs
@@ -1,10 +1,11 @@
+using CounterDrone.Core.Algorithms;
using System;
using System.Collections.Generic;
using System.Text.Json;
namespace CounterDrone.Core.Simulation
{
- public class ControlZone
+ public class ControlZoneEntity
{
public string Id { get; }
public string Name { get; }
@@ -12,9 +13,9 @@ namespace CounterDrone.Core.Simulation
public float MinAltitude { get; }
public float MaxAltitude { get; }
- public struct Vector3 { public float X, Y, Z; }
+ public struct Vector3 { public float X { get; set; } public float Y { get; set; } public float Z { get; set; } }
- public ControlZone(string id, string name, string verticesJson, float minAlt, float maxAlt)
+ public ControlZoneEntity(string id, string name, string verticesJson, float minAlt, float maxAlt)
{
Id = id;
Name = name;
@@ -25,24 +26,13 @@ namespace CounterDrone.Core.Simulation
public bool ContainsPoint(float x, float y, float z)
{
- // 高度检查
if (y < MinAltitude || y > MaxAltitude) return false;
- // Point-in-Polygon(射线法)
- if (Vertices.Count < 3) return false;
-
- bool inside = false;
- int j = Vertices.Count - 1;
+ var vertices2D = new (float X, float Z)[Vertices.Count];
for (int i = 0; i < Vertices.Count; i++)
- {
- var xi = Vertices[i].X; var zi = Vertices[i].Z;
- var xj = Vertices[j].X; var zj = Vertices[j].Z;
+ vertices2D[i] = (Vertices[i].X, Vertices[i].Z);
- if ((zi > z) != (zj > z) && x < (xj - xi) * (z - zi) / (zj - zi) + xi)
- inside = !inside;
- j = i;
- }
- return inside;
+ return Kinematics.PointInPolygon(x, z, vertices2D);
}
}
}
diff --git a/src/CounterDrone.Core/Simulation/DroneEntity.cs b/src/CounterDrone.Core/Simulation/DroneEntity.cs
index 73e5101..e4f2df3 100644
--- a/src/CounterDrone.Core/Simulation/DroneEntity.cs
+++ b/src/CounterDrone.Core/Simulation/DroneEntity.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Simulation
@@ -42,6 +43,8 @@ namespace CounterDrone.Core.Simulation
// 编队偏移
ApplyFormationOffset(formationIndex, spacing, mode);
+ PosX += FormationOffsetX;
+ PosY += FormationOffsetY;
}
private void ApplyFormationOffset(int index, float spacing, FormationMode mode)
@@ -77,25 +80,30 @@ namespace CounterDrone.Core.Simulation
var dy = (float)(target.PosY - PosY);
var dz = (float)(target.PosZ - PosZ);
var dist = (float)Math.Sqrt(dx * dx + dy * dy + dz * dz);
+ var step = speedMs * deltaTime;
- if (dist < 1.0f)
+ if (dist < 1.0f || step >= dist)
{
+ PosX = (float)target.PosX;
+ PosY = (float)target.PosY;
+ PosZ = (float)target.PosZ;
CurrentWaypointIndex++;
+ if (CurrentWaypointIndex >= Route.Count - 1)
+ Status = DroneStatus.ReachedTarget;
return;
}
// 移动方向
- var step = speedMs * deltaTime;
var ratio = step / dist;
PosX += dx * ratio;
PosY += dy * ratio;
PosZ += dz * ratio;
// 风偏
- var windVec = WindToVector(windDir, windSpeed);
- PosX += windVec.X * deltaTime;
- PosY += windVec.Y * deltaTime;
- PosZ += windVec.Z * deltaTime;
+ (float wx, float wy, float wz) = Kinematics.WindToVector(windDir, windSpeed);
+ PosX += wx * deltaTime;
+ PosY += wy * deltaTime;
+ PosZ += wz * deltaTime;
}
public void ApplyDamage(float damage)
@@ -113,23 +121,5 @@ namespace CounterDrone.Core.Simulation
{
Status = DroneStatus.ZoneIntruded;
}
-
- private static (float X, float Y, float Z) WindToVector(WindDirection dir, float speed)
- {
- var angle = dir switch
- {
- WindDirection.N => 0f,
- WindDirection.NE => 45f,
- WindDirection.E => 90f,
- WindDirection.SE => 135f,
- WindDirection.S => 180f,
- WindDirection.SW => 225f,
- WindDirection.W => 270f,
- WindDirection.NW => 315f,
- _ => 0f,
- };
- var rad = angle * (float)Math.PI / 180f;
- return ((float)Math.Sin(rad) * speed, 0, (float)Math.Cos(rad) * speed);
- }
}
}
diff --git a/src/CounterDrone.Core/Simulation/MunitionEntity.cs b/src/CounterDrone.Core/Simulation/MunitionEntity.cs
index a140349..95f4dc2 100644
--- a/src/CounterDrone.Core/Simulation/MunitionEntity.cs
+++ b/src/CounterDrone.Core/Simulation/MunitionEntity.cs
@@ -1,3 +1,4 @@
+using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Simulation
@@ -22,6 +23,8 @@ namespace CounterDrone.Core.Simulation
private readonly float _azimuth;
private readonly float _startX, _startY, _startZ;
+ private bool _hasExceededReleaseAltitude;
+
public MunitionEntity(string id, PlatformType launchMode, AerosolType aerosolType,
float startX, float startY, float startZ,
float targetX, float targetY, float targetZ,
@@ -36,12 +39,13 @@ namespace CounterDrone.Core.Simulation
_muzzleVelocity = muzzleVelocity;
ReleaseAltitude = releaseAltitude;
- // 计算发射角(简化抛物线)
+ // 根据目标距离反算发射角(取低弹道)
var dx = targetX - startX;
var dz = targetZ - startZ;
var horizontalDist = (float)System.Math.Sqrt(dx * dx + dz * dz);
- _azimuth = horizontalDist > 0 ? (float)System.Math.Atan2(dx, dz) : 0;
- _launchAngle = 45f * (float)System.Math.PI / 180f;
+ _azimuth = horizontalDist > 0.001f ? (float)System.Math.Atan2(dx, dz) : 0;
+
+ _launchAngle = Kinematics.CalculateLaunchAngle(horizontalDist, muzzleVelocity, releaseAltitude);
}
public void Update(float deltaTime)
@@ -51,17 +55,15 @@ namespace CounterDrone.Core.Simulation
if (LaunchMode == PlatformType.GroundBased)
{
- // 抛物线弹道
- var v0 = _muzzleVelocity;
- var cosA = (float)System.Math.Cos(_launchAngle);
- var sinA = (float)System.Math.Sin(_launchAngle);
+ (float x, float y, float z) = Kinematics.ParabolicPosition(
+ _startX, _startY, _startZ, _launchAngle, _azimuth, _muzzleVelocity, _time);
+ PosX = x; PosY = y; PosZ = z;
- var horizontalDist = v0 * cosA * _time;
- PosX = _startX + horizontalDist * (float)System.Math.Sin(_azimuth);
- PosZ = _startZ + horizontalDist * (float)System.Math.Cos(_azimuth);
- PosY = _startY + v0 * sinA * _time - 0.5f * 9.81f * _time * _time;
+ // 必须先从上方越过释放高度,再回落时才触发
+ if (PosY > ReleaseAltitude)
+ _hasExceededReleaseAltitude = true;
- if (PosY <= ReleaseAltitude)
+ if (_hasExceededReleaseAltitude && PosY <= ReleaseAltitude)
{
PosY = ReleaseAltitude;
HasArrived = true;
diff --git a/src/CounterDrone.Core/Simulation/SimTypes.cs b/src/CounterDrone.Core/Simulation/SimTypes.cs
index d149b90..56b8ad1 100644
--- a/src/CounterDrone.Core/Simulation/SimTypes.cs
+++ b/src/CounterDrone.Core/Simulation/SimTypes.cs
@@ -4,16 +4,12 @@ namespace CounterDrone.Core.Simulation
{
public enum SimulationState
{
- Idle,
- Running,
- Paused,
- Stopped,
- Completed,
+ Idle, Running, Paused, Stopped, Completed,
}
public class SimEvent
{
- public SimEventType Type { get; set; }
+ public Models.SimEventType Type { get; set; }
public float OccurredAt { get; set; }
public string SourceId { get; set; } = string.Empty;
public string TargetId { get; set; } = string.Empty;
@@ -21,23 +17,9 @@ namespace CounterDrone.Core.Simulation
public string Description { get; set; } = string.Empty;
}
- public enum SimEventType
- {
- TargetDetected,
- MunitionLaunched,
- CloudGenerated,
- DroneEnteredCloud,
- DroneDestroyed,
- DroneReachedTarget,
- ZoneIntruded,
- WaypointReached,
- SimulationEnd,
- }
-
public class EventQueue
{
private readonly Queue _queue = new();
-
public void Enqueue(SimEvent e) => _queue.Enqueue(e);
public SimEvent Dequeue() => _queue.Dequeue();
public int Count => _queue.Count;
@@ -69,9 +51,6 @@ namespace CounterDrone.Core.Simulation
public enum DroneStatus
{
- Flying,
- Destroyed,
- ReachedTarget,
- ZoneIntruded,
+ Flying, Destroyed, ReachedTarget, ZoneIntruded,
}
}
diff --git a/src/CounterDrone.Core/Simulation/SimulationEngine.cs b/src/CounterDrone.Core/Simulation/SimulationEngine.cs
index e7e0d11..12dcc0f 100644
--- a/src/CounterDrone.Core/Simulation/SimulationEngine.cs
+++ b/src/CounterDrone.Core/Simulation/SimulationEngine.cs
@@ -2,13 +2,23 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
-using SQLite;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using CounterDrone.Core.Services;
+using SQLite;
namespace CounterDrone.Core.Simulation
{
+ /// 发射计划中的单次事件
+ public class FireEvent
+ {
+ public float FireTime;
+ public int PlatformIndex; // 第几个发射平台(0-based)
+ public float TargetX, TargetY, TargetZ;
+ public float MuzzleVelocity;
+ }
+
+ /// 仿真引擎 — 纯执行器,不包含任何决策逻辑
public class SimulationEngine
{
private readonly IScenarioService _scenarioService;
@@ -16,32 +26,35 @@ namespace CounterDrone.Core.Simulation
private readonly IDamageModel _damageModel;
private readonly IPathProvider _paths;
- // 状态
public SimulationState State { get; private set; } = SimulationState.Idle;
public int FrameIndex { get; private set; }
public float SimulationTime { get; private set; }
private float _tickInterval;
- // 实体
private List _drones = new();
private List _platforms = new();
- private List _detections = new();
private List _munitions = new();
private List _clouds = new();
- private List _zones = new();
+ private List _zones = new();
- // 配置快照
private CombatScene _scene = new();
- private CloudDispersal _cloudConfig = new();
private AmmunitionSpec _ammoSpec = new();
private int _tickRate = 20;
- // 内部
- private readonly EventQueue _eventQueue = new();
+ // 发射计划:算法预计算,引擎按时间表执行
+ private readonly List _fireSchedule = new();
+ private int _nextFireIndex;
+
+ private readonly List _allEvents = new();
private SQLiteConnection _frameDb = null!;
private string _taskId = string.Empty;
private int _entityCounter;
+ public IReadOnlyList Drones => _drones;
+ public IReadOnlyList Clouds => _clouds;
+ public IReadOnlyList Munitions => _munitions;
+ public IReadOnlyList Events => _allEvents;
+
public SimulationEngine(IScenarioService scenarioService, FrameDataStore frameStore,
IDamageModel damageModel, IPathProvider paths)
{
@@ -51,6 +64,14 @@ namespace CounterDrone.Core.Simulation
_paths = paths;
}
+ /// 设置发射计划(来自推荐算法),在 Initialize 之前调用
+ public void SetFireSchedule(List schedule)
+ {
+ _fireSchedule.Clear();
+ _fireSchedule.AddRange(schedule.OrderBy(f => f.FireTime));
+ _nextFireIndex = 0;
+ }
+
public void Initialize(string taskId)
{
_taskId = taskId;
@@ -58,168 +79,118 @@ namespace CounterDrone.Core.Simulation
if (config == null) throw new InvalidOperationException("Task not found");
_scene = config.Scene;
- _cloudConfig = config.Cloud;
_tickRate = config.Task.TickRate;
_tickInterval = 1.0f / _tickRate;
- // 加载弹药规格(取第一个匹配的)
- var ammoDb = new SQLiteConnection(_paths.GetMainDbPath());
- _ammoSpec = ammoDb.Table()
- .FirstOrDefault(a => a.AerosolType == _cloudConfig.AerosolType)
- ?? new AmmunitionSpec { InitialRadius = 50, CoreDensity = 1.0f, DispersionRateBase = 5, MaxRadius = 200, MaxDuration = 60 };
- ammoDb.Close();
+ using (var ammoDb = new SQLiteConnection(_paths.GetMainDbPath()))
+ {
+ _ammoSpec = ammoDb.Table()
+ .FirstOrDefault(a => a.AerosolType == config.Cloud.AerosolType)
+ ?? new AmmunitionSpec { InitialRadius = 50, CoreDensity = 1.0f, DispersionRateBase = 5, MaxRadius = 200, MaxDuration = 60 };
+ }
- // 实例化无人机
+ // 无人机
_drones.Clear();
foreach (var target in config.Targets)
{
var mode = (FormationMode)config.Route.FormationMode;
var spacing = (float)config.Route.FormationSpacing;
for (int i = 0; i < target.Quantity; i++)
- {
- var drone = new DroneEntity($"drone_{++_entityCounter}", target.GroupId,
- target, config.Waypoints, i, spacing, mode);
- _drones.Add(drone);
- }
+ _drones.Add(new DroneEntity($"drone_{++_entityCounter}", target.GroupId,
+ target, config.Waypoints, i, spacing, mode));
}
- // 实例化装备
+ // 装备
_platforms.Clear();
- _detections.Clear();
foreach (var equip in config.Equipment)
{
- if (equip.EquipmentRole == (int)EquipmentRole.Detection)
- {
- _detections.Add(new DetectionEntity($"det_{++_entityCounter}", equip));
- }
- else
+ if (equip.EquipmentRole != (int)EquipmentRole.Detection)
{
for (int i = 0; i < equip.Quantity; i++)
- {
_platforms.Add(new PlatformEntity($"plat_{++_entityCounter}", equip));
- }
}
}
- // 实例化管控区域
+ // 管控区域
_zones.Clear();
foreach (var z in config.ControlZones)
- {
- _zones.Add(new ControlZone(z.Id, z.Name, z.VerticesJson,
+ _zones.Add(new ControlZoneEntity(z.Id, z.Name, z.VerticesJson,
(float)z.MinAltitude, (float)z.MaxAltitude));
- }
_munitions.Clear();
_clouds.Clear();
- _eventQueue.Clear();
+ _allEvents.Clear();
FrameIndex = 0;
SimulationTime = 0;
_entityCounter = 0;
+ _nextFireIndex = 0;
- // 打开帧数据库
_frameDb = _frameStore.CreateOrOpen(_taskId);
-
State = SimulationState.Running;
}
public SimulationFrameResult Tick(float deltaTime)
{
- if (State != SimulationState.Running) return new SimulationFrameResult { State = State };
+ if (State != SimulationState.Running)
+ return new SimulationFrameResult { State = State, FrameIndex = FrameIndex, SimulationTime = SimulationTime };
SimulationTime += deltaTime;
FrameIndex++;
- _eventQueue.Clear();
- var events = new List();
+ var frameEvents = new List();
- // 1. 探测阶段
- foreach (var det in _detections)
+ // 1. 按发射计划执行
+ while (_nextFireIndex < _fireSchedule.Count &&
+ _fireSchedule[_nextFireIndex].FireTime <= SimulationTime)
{
- foreach (var drone in _drones.Where(d => d.Status == DroneStatus.Flying))
+ var fe = _fireSchedule[_nextFireIndex++];
+ var platform = _platforms.Count > fe.PlatformIndex
+ ? _platforms[fe.PlatformIndex] : null;
+ if (platform != null && platform.Ready)
{
- if (det.CanDetect(drone))
+ platform.Fire();
+ var m = new MunitionEntity($"mun_{++_entityCounter}",
+ platform.PlatformType ?? Models.PlatformType.GroundBased,
+ platform.AerosolType ?? Models.AerosolType.InertGas,
+ platform.PosX, platform.PosY, platform.PosZ,
+ fe.TargetX, fe.TargetY, fe.TargetZ,
+ fe.MuzzleVelocity,
+ (float)_ammoSpec.InitialRadius > 0 ? 300f : 300f);
+ _munitions.Add(m);
+ frameEvents.Add(new SimEvent
{
- _eventQueue.Enqueue(new SimEvent
- {
- Type = SimEventType.TargetDetected,
- OccurredAt = SimulationTime,
- SourceId = det.Id,
- TargetId = drone.Id,
- Description = $"探测到目标 {drone.Id}",
- });
- }
- }
- }
-
- // 2. 火控决策 — 简化:探测到目标后分配发射
- while (_eventQueue.HasEvents)
- {
- var evt = _eventQueue.Dequeue();
- if (evt.Type == SimEventType.TargetDetected)
- {
- var platform = _platforms.FirstOrDefault(p => p.Ready);
- var drone = _drones.FirstOrDefault(d => d.Id == evt.TargetId);
- if (platform != null && drone != null)
- {
- platform.Fire();
- var munition = new MunitionEntity(
- $"mun_{++_entityCounter}",
- platform.PlatformType ?? Models.PlatformType.GroundBased,
- platform.AerosolType ?? Models.AerosolType.InertGas,
- platform.PosX, platform.PosY, platform.PosZ,
- drone.PosX, drone.PosY, drone.PosZ,
- // Use default muzzle velocity from ammo spec or platform config
- 800f,
- (float)_cloudConfig.DisperseHeight);
- _munitions.Add(munition);
-
- events.Add(new SimEvent
- {
- Type = SimEventType.MunitionLaunched,
- OccurredAt = SimulationTime,
- SourceId = platform.Id,
- TargetId = drone.Id,
- Description = $"发射弹药 {munition.Id} → 目标 {drone.Id}",
- });
- }
- }
- }
-
- // 3. 弹药飞行
- foreach (var munition in _munitions.ToList())
- {
- munition.Update(deltaTime);
- if (munition.HasArrived)
- {
- // 生成云团
- var dispersion = AlgorithmFactory.Create();
- dispersion.Initialize(_ammoSpec, _scene,
- new Algorithms.Vector3(munition.PosX, munition.PosY, munition.PosZ),
- SimulationTime);
-
- var cloud = new CloudEntity($"cloud_{++_entityCounter}",
- munition.AerosolType, dispersion, SimulationTime);
- _clouds.Add(cloud);
- _munitions.Remove(munition);
-
- events.Add(new SimEvent
- {
- Type = SimEventType.CloudGenerated,
- OccurredAt = SimulationTime,
- SourceId = cloud.Id,
- Description = $"云团生成 at ({cloud.Dispersion.Center.X:F0}, {cloud.Dispersion.Center.Y:F0}, {cloud.Dispersion.Center.Z:F0})",
+ Type = SimEventType.MunitionLaunched, OccurredAt = SimulationTime,
+ SourceId = platform.Id, Description = $"计划发射 {m.Id}",
});
}
}
- // 4. 云团演化
- foreach (var cloud in _clouds.ToList())
+ // 2. 弹药飞行 → 云团生成
+ foreach (var m in _munitions.ToList())
{
- cloud.Tick(deltaTime, (float)_scene.WindSpeed, (WindDirection)_scene.WindDirection);
- if (cloud.IsDissipated)
- _clouds.Remove(cloud);
+ m.Update(deltaTime);
+ if (m.HasArrived)
+ {
+ var disp = AlgorithmFactory.Create();
+ disp.Initialize(_ammoSpec, _scene,
+ new Algorithms.Vector3(m.PosX, m.PosY, m.PosZ), SimulationTime);
+ _clouds.Add(new CloudEntity($"cloud_{++_entityCounter}", m.AerosolType, disp, SimulationTime));
+ _munitions.Remove(m);
+ frameEvents.Add(new SimEvent
+ {
+ Type = SimEventType.CloudGenerated, OccurredAt = SimulationTime,
+ Description = $"云团生成",
+ });
+ }
}
- // 5. 毁伤判定
+ // 3. 云团演化
+ foreach (var c in _clouds.ToList())
+ {
+ c.Tick(deltaTime, (float)_scene.WindSpeed, (WindDirection)_scene.WindDirection);
+ if (c.IsDissipated) _clouds.Remove(c);
+ }
+
+ // 4. 毁伤判定
foreach (var drone in _drones.Where(d => d.Status == DroneStatus.Flying))
{
foreach (var cloud in _clouds)
@@ -227,50 +198,36 @@ namespace CounterDrone.Core.Simulation
if (cloud.ContainsPoint(drone.PosX, drone.PosY, drone.PosZ))
{
drone.ExposureTime += deltaTime;
- var density = cloud.Dispersion.CoreDensity;
- var damage = _damageModel.CalculateDamage(
- drone.TargetType, drone.PowerType,
- cloud.AerosolType, density,
- drone.ExposureTime, deltaTime);
-
- drone.ApplyDamage(damage);
-
+ var dmg = _damageModel.CalculateDamage(
+ drone.TargetType, drone.PowerType, cloud.AerosolType,
+ cloud.Dispersion.CoreDensity, drone.ExposureTime, deltaTime);
+ drone.ApplyDamage(dmg);
if (drone.Status == DroneStatus.Destroyed)
- {
- events.Add(new SimEvent
+ frameEvents.Add(new SimEvent
{
- Type = SimEventType.DroneDestroyed,
- OccurredAt = SimulationTime,
- TargetId = drone.Id,
- Description = $"无人机 {drone.Id} 被摧毁",
+ Type = SimEventType.DroneDestroyed, OccurredAt = SimulationTime,
+ TargetId = drone.Id, Description = $"无人机 {drone.Id} 被摧毁",
});
- }
}
}
}
- // 6. 无人机移动
+ // 5. 无人机移动
foreach (var drone in _drones.Where(d => d.Status == DroneStatus.Flying))
{
drone.Update(deltaTime, (float)_scene.WindSpeed, (WindDirection)_scene.WindDirection);
-
if (drone.Status == DroneStatus.ReachedTarget)
- {
- events.Add(new SimEvent
+ frameEvents.Add(new SimEvent
{
- Type = SimEventType.DroneReachedTarget,
- OccurredAt = SimulationTime,
- SourceId = drone.Id,
- Description = $"无人机 {drone.Id} 到达攻击目标",
+ Type = SimEventType.DroneReachedTarget, OccurredAt = SimulationTime,
+ SourceId = drone.Id, Description = "到达攻击目标",
});
- }
}
- // 7. 平台冷却
- foreach (var plat in _platforms)
- plat.Update(deltaTime);
+ // 6. 平台冷却
+ foreach (var p in _platforms) p.Update(deltaTime);
- // 8. 管控区域判定
+ // 7. 管控区域
foreach (var drone in _drones.Where(d => d.Status == DroneStatus.Flying))
{
foreach (var zone in _zones)
@@ -278,63 +235,43 @@ namespace CounterDrone.Core.Simulation
if (zone.ContainsPoint(drone.PosX, drone.PosY, drone.PosZ))
{
drone.MarkZoneIntruded();
- events.Add(new SimEvent
+ frameEvents.Add(new SimEvent
{
- Type = SimEventType.ZoneIntruded,
- OccurredAt = SimulationTime,
- SourceId = drone.Id,
- TargetId = zone.Id,
- Description = $"无人机 {drone.Id} 侵入管控区域 {zone.Name}",
+ Type = SimEventType.ZoneIntruded, OccurredAt = SimulationTime,
+ SourceId = drone.Id, TargetId = zone.Id, Description = $"侵入 {zone.Name}",
});
}
}
}
- // 9. 结束判定
- var allDone = _drones.All(d => d.Status != DroneStatus.Flying);
- if (allDone)
+ // 8. 结束判定
+ if (_drones.All(d => d.Status != DroneStatus.Flying))
{
State = SimulationState.Completed;
- events.Add(new SimEvent
- {
- Type = SimEventType.SimulationEnd,
- OccurredAt = SimulationTime,
- Description = "仿真结束",
- });
+ frameEvents.Add(new SimEvent { Type = SimEventType.SimulationEnd, OccurredAt = SimulationTime });
}
- // 录制帧数据
+ _allEvents.AddRange(frameEvents);
+
+ // 录制帧
var snapshots = CollectSnapshots();
_frameStore.WriteFrame(_frameDb, _taskId, FrameIndex, SimulationTime, snapshots);
-
_frameDb.Commit();
return new SimulationFrameResult
{
- EntitySnapshots = snapshots,
- NewEvents = events,
- State = State,
- FrameIndex = FrameIndex,
- SimulationTime = SimulationTime,
+ EntitySnapshots = snapshots, NewEvents = frameEvents,
+ State = State, FrameIndex = FrameIndex, SimulationTime = SimulationTime,
};
}
public void Pause() { if (State == SimulationState.Running) State = SimulationState.Paused; }
public void Resume() { if (State == SimulationState.Paused) State = SimulationState.Running; }
- public void Stop()
- {
- State = SimulationState.Stopped;
- _frameDb?.Close();
- }
-
- public IReadOnlyList Drones => _drones;
- public IReadOnlyList Clouds => _clouds;
- public IReadOnlyList Munitions => _munitions;
+ public void Stop() { State = SimulationState.Stopped; _frameDb?.Close(); }
private List CollectSnapshots()
{
var list = new List();
-
foreach (var d in _drones)
{
var stage = _damageModel.GetDamageStage(1 - d.Hp);
@@ -342,43 +279,16 @@ namespace CounterDrone.Core.Simulation
{
EntityId = d.Id, EntityType = Models.EntityType.Drone,
PosX = d.PosX, PosY = d.PosY, PosZ = d.PosZ,
- StateData = JsonSerializer.Serialize(new { damageStage = (int)stage, hp = d.Hp, status = d.Status.ToString() }),
+ StateData = JsonSerializer.Serialize(new { damageStage = (int)stage, hp = d.Hp }),
});
}
-
- foreach (var p in _platforms)
- {
- list.Add(new EntitySnapshot
- {
- EntityId = p.Id, EntityType = Models.EntityType.Platform,
- PosX = p.PosX, PosY = p.PosY, PosZ = p.PosZ,
- StateData = JsonSerializer.Serialize(new { ready = p.Ready, munitions = p.MunitionCount }),
- });
- }
-
- foreach (var d in _detections)
- {
- list.Add(new EntitySnapshot
- {
- EntityId = d.Id, EntityType = Models.EntityType.DetectionEquip,
- PosX = d.PosX, PosY = d.PosY, PosZ = d.PosZ,
- });
- }
-
foreach (var c in _clouds)
- {
list.Add(new EntitySnapshot
{
EntityId = c.Id, EntityType = Models.EntityType.Cloud,
PosX = c.Dispersion.Center.X, PosY = c.Dispersion.Center.Y, PosZ = c.Dispersion.Center.Z,
- StateData = JsonSerializer.Serialize(new
- {
- radius = c.Dispersion.Radius,
- opacity = c.Dispersion.Particles.Opacity,
- }),
+ StateData = JsonSerializer.Serialize(new { radius = c.Dispersion.Radius, opacity = c.Dispersion.Particles.Opacity }),
});
- }
-
return list;
}
}
diff --git a/test/unit/CounterDrone.Core.Tests/ControlZoneEntityTests.cs b/test/unit/CounterDrone.Core.Tests/ControlZoneEntityTests.cs
new file mode 100644
index 0000000..83281be
--- /dev/null
+++ b/test/unit/CounterDrone.Core.Tests/ControlZoneEntityTests.cs
@@ -0,0 +1,43 @@
+using CounterDrone.Core.Simulation;
+using Xunit;
+
+namespace CounterDrone.Core.Tests
+{
+ public class ControlZoneEntityTests
+ {
+ private const string TestZoneJson =
+ "[{\"X\":1200,\"Y\":0,\"Z\":-200},{\"X\":1800,\"Y\":0,\"Z\":-200},{\"X\":1800,\"Y\":0,\"Z\":200},{\"X\":1200,\"Y\":0,\"Z\":200}]";
+
+ [Fact]
+ public void ContainsPoint_Center_ReturnsTrue()
+ {
+ var zone = new ControlZoneEntity("z1", "test", TestZoneJson, 0, 1000);
+ // 手动检查顶点数
+ Assert.Equal(4, zone.Vertices.Count);
+ Assert.Equal(1200f, zone.Vertices[0].X, 1);
+ Assert.Equal(-200f, zone.Vertices[0].Z, 1);
+ Assert.True(zone.ContainsPoint(1500, 300, 0), "点(1500,300,0)应在矩形内");
+ }
+
+ [Fact]
+ public void ContainsPoint_OutsideX_ReturnsFalse()
+ {
+ var zone = new ControlZoneEntity("z1", "test", TestZoneJson, 0, 1000);
+ Assert.False(zone.ContainsPoint(2000, 300, 0), "点(2000,300,0)应在矩形外");
+ }
+
+ [Fact]
+ public void ContainsPoint_OutsideZ_ReturnsFalse()
+ {
+ var zone = new ControlZoneEntity("z1", "test", TestZoneJson, 0, 1000);
+ Assert.False(zone.ContainsPoint(1500, 300, 500), "点(1500,300,500) Z超出");
+ }
+
+ [Fact]
+ public void ContainsPoint_OutsideAltitude_ReturnsFalse()
+ {
+ var zone = new ControlZoneEntity("z1", "test", TestZoneJson, 0, 1000);
+ Assert.False(zone.ContainsPoint(1500, 1200, 0), "海拔 1200 超出上限");
+ }
+ }
+}
diff --git a/test/unit/CounterDrone.Core.Tests/DefenseAdvisorRealityTests.cs b/test/unit/CounterDrone.Core.Tests/DefenseAdvisorRealityTests.cs
new file mode 100644
index 0000000..3bd4fd0
--- /dev/null
+++ b/test/unit/CounterDrone.Core.Tests/DefenseAdvisorRealityTests.cs
@@ -0,0 +1,72 @@
+using System.Collections.Generic;
+using CounterDrone.Core.Algorithms;
+using CounterDrone.Core.Models;
+using Xunit;
+
+namespace CounterDrone.Core.Tests
+{
+ public class DefenseAdvisorRealityTests
+ {
+ [Fact]
+ public void PistonDrone_RecommendsReachableCloudPosition()
+ {
+ var threat = new ThreatProfile
+ {
+ Environment = new CombatScene
+ {
+ WindSpeed = 3,
+ WindDirection = (int)WindDirection.W,
+ },
+ Targets = new List
+ {
+ new TargetConfig
+ {
+ TargetType = (int)TargetType.Piston,
+ PowerType = (int)PowerType.Piston,
+ Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500,
+ },
+ },
+ Route = new RoutePlan { FormationMode = (int)FormationMode.Single },
+ Waypoints = new List
+ {
+ new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 },
+ new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 },
+ },
+ };
+
+ var advisor = new DefaultDefenseAdvisor();
+ var rec = advisor.Recommend(threat);
+
+ // 1. 选型正确
+ Assert.Equal(AerosolType.InertGas, rec.Best.RecommendedAerosolType);
+
+ // 2. 云团位置应在航路上
+ var c = rec.Best.RecommendedCloud;
+ Assert.True(c.PositionX > 0 && c.PositionX < 20000,
+ $"云团 X={c.PositionX} 应在航路范围内");
+ Assert.True(c.PositionY >= 400 && c.PositionY <= 600,
+ $"云团 Y={c.PositionY} 应接近飞行高度 500");
+
+ // 3. 平台部署应有合理位置
+ Assert.NotEmpty(rec.Best.Platforms);
+ var p = rec.Best.Platforms[0];
+
+ // 4. 炮弹能打到吗?用运动学验证
+ var muzzleV = 800f;
+ var releaseAlt = (float)c.DisperseHeight;
+ var targetDist = (float)System.Math.Sqrt(
+ (c.PositionX - p.Position.X) * (c.PositionX - p.Position.X) +
+ (c.PositionZ - p.Position.Z) * (c.PositionZ - p.Position.Z));
+
+ var launchAngle = Kinematics.CalculateLaunchAngle(targetDist, muzzleV, releaseAlt);
+ Assert.True(launchAngle > 0 && launchAngle < 1.57f,
+ $"发射角 {launchAngle * 180 / System.Math.PI:F1}° 应在 0-90°");
+
+ // 5. 弹道能到达释放高度
+ var peakHeight = muzzleV * muzzleV * (float)System.Math.Sin(launchAngle)
+ * (float)System.Math.Sin(launchAngle) / (2f * 9.81f);
+ Assert.True(peakHeight >= releaseAlt,
+ $"弹道顶点 {peakHeight:F0}m ≥ 释放高度 {releaseAlt:F0}m");
+ }
+ }
+}
diff --git a/test/unit/CounterDrone.Core.Tests/DroneEntityTests.cs b/test/unit/CounterDrone.Core.Tests/DroneEntityTests.cs
new file mode 100644
index 0000000..8f32139
--- /dev/null
+++ b/test/unit/CounterDrone.Core.Tests/DroneEntityTests.cs
@@ -0,0 +1,111 @@
+using System.Collections.Generic;
+using CounterDrone.Core.Models;
+using CounterDrone.Core.Simulation;
+using Xunit;
+
+namespace CounterDrone.Core.Tests
+{
+ public class DroneEntityTests
+ {
+ private TargetConfig CreateConfig(float speed = 120, float altitude = 300)
+ {
+ return new TargetConfig
+ {
+ TargetType = (int)TargetType.Piston,
+ PowerType = (int)PowerType.Piston,
+ TypicalSpeed = speed,
+ TypicalAltitude = altitude,
+ Wingspan = 2.5,
+ Quantity = 1,
+ };
+ }
+
+ private List CreateRoute(float fromX, float toX, float altitude, float speed)
+ {
+ return new List
+ {
+ new Waypoint { PosX = fromX, PosY = altitude, PosZ = 0, Altitude = altitude, Speed = speed },
+ new Waypoint { PosX = toX, PosY = altitude, PosZ = 0, Altitude = altitude, Speed = speed },
+ };
+ }
+
+ [Fact]
+ public void InitialPosition_IsFirstWaypoint()
+ {
+ var route = CreateRoute(100, 500, 300, 120);
+ var drone = new DroneEntity("d1", "g1", CreateConfig(), route, 0, 50, FormationMode.Single);
+
+ Assert.Equal(100, drone.PosX, 1);
+ Assert.Equal(300, drone.PosY, 1); // 高度 = waypoint.PosY
+ }
+
+ [Fact]
+ public void Update_MovesTowardTarget()
+ {
+ var route = CreateRoute(0, 1000, 300, 120);
+ var drone = new DroneEntity("d1", "g1", CreateConfig(), route, 0, 50, FormationMode.Single);
+
+ float prevX = drone.PosX;
+ drone.Update(1f, 0, WindDirection.N);
+
+ Assert.True(drone.PosX > prevX, "无人机应向目标移动");
+ }
+
+ [Fact]
+ public void Update_ReachesTarget_StatusChanges()
+ {
+ var route = CreateRoute(0, 100, 300, 360); // 360 km/h = 100 m/s
+ var drone = new DroneEntity("d1", "g1", CreateConfig(360), route, 0, 50, FormationMode.Single);
+
+ // 100m 距离, 100m/s 速度, 1s 后应到达
+ drone.Update(2f, 0, WindDirection.N);
+
+ Assert.Equal(DroneStatus.ReachedTarget, drone.Status);
+ }
+
+ [Fact]
+ public void ApplyDamage_ReducesHp()
+ {
+ var drone = new DroneEntity("d1", "g1", CreateConfig(),
+ CreateRoute(0, 100, 300, 60), 0, 50, FormationMode.Single);
+
+ drone.ApplyDamage(0.4f);
+ Assert.Equal(0.6f, drone.Hp, 0.01f);
+ Assert.Equal(DroneStatus.Flying, drone.Status);
+ }
+
+ [Fact]
+ public void ApplyDamage_ExceedingHp_DestroysDrone()
+ {
+ var drone = new DroneEntity("d1", "g1", CreateConfig(),
+ CreateRoute(0, 100, 300, 60), 0, 50, FormationMode.Single);
+
+ drone.ApplyDamage(1.5f);
+ Assert.True(drone.Hp <= 0);
+ Assert.Equal(DroneStatus.Destroyed, drone.Status);
+ }
+
+ [Fact]
+ public void Formation_YOffsetsDiffer()
+ {
+ var route = CreateRoute(0, 100, 300, 60);
+ var d0 = new DroneEntity("d0", "g1", CreateConfig(), route, 0, 50, FormationMode.Formation);
+ var d1 = new DroneEntity("d1", "g1", CreateConfig(), route, 1, 50, FormationMode.Formation);
+
+ Assert.NotEqual(d0.PosY, d1.PosY);
+ }
+
+ [Fact]
+ public void Wind_AffectsPosition()
+ {
+ var route = CreateRoute(0, 1000, 300, 60);
+ var drone = new DroneEntity("d1", "g1", CreateConfig(), route, 0, 50, FormationMode.Single);
+
+ float xBefore = drone.PosX;
+ drone.Update(1f, 20f, WindDirection.E); // 东风 20m/s
+
+ // 东风 → +X 方向偏移
+ Assert.True(drone.PosX > xBefore + 15f, "东风应显著增加 X 位移");
+ }
+ }
+}
diff --git a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs
new file mode 100644
index 0000000..2d3bf4a
--- /dev/null
+++ b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs
@@ -0,0 +1,330 @@
+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 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();
+
+ _mainDb.Insert(new AmmunitionSpec
+ {
+ Id = "ammo-inert", AerosolType = (int)AerosolType.InertGas,
+ Name = "惰性气体弹", InitialRadius = 50, CoreDensity = 1.0,
+ DispersionRateBase = 5, MaxRadius = 500, MaxDuration = 120,
+ EffectiveConcentration = 0.05,
+ });
+ _mainDb.Insert(new AmmunitionSpec
+ {
+ Id = "ammo-active", AerosolType = (int)AerosolType.ActiveMaterial,
+ Name = "活性材料弹", InitialRadius = 30, CoreDensity = 2.0,
+ DispersionRateBase = 3, MaxRadius = 400, MaxDuration = 90,
+ EffectiveConcentration = 0.1,
+ });
+ _mainDb.Insert(new AmmunitionSpec
+ {
+ Id = "ammo-fuel", AerosolType = (int)AerosolType.ActiveFuel,
+ Name = "活性燃料弹", InitialRadius = 40, CoreDensity = 1.5,
+ DispersionRateBase = 4, MaxRadius = 450, MaxDuration = 100,
+ EffectiveConcentration = 0.03,
+ });
+
+ _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)
+ {
+ var engine = new SimulationEngine(_scenario, _frameStore, new DamageModelRouter(), _paths);
+
+ // 如果有推荐方案,生成发射计划
+ var detail = _scenario.GetTaskDetail(_taskId)!;
+ if (detail.Equipment.Any(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform))
+ {
+ var schedule = BuildFireSchedule(detail);
+ if (schedule.Count > 0) engine.SetFireSchedule(schedule);
+ }
+
+ engine.Initialize(_taskId);
+ for (int i = 0; i < maxTicks; i++)
+ {
+ var r = engine.Tick(tickDt);
+ if (r.State == SimulationState.Completed) break;
+ }
+ engine.Stop();
+ return engine;
+ }
+
+ /// 从推荐方案的时机参数生成发射计划
+ private static List BuildFireSchedule(TaskFullConfig detail)
+ {
+ var schedule = new List();
+ var cloud = detail.Cloud;
+
+ if (!cloud.RecommendedTiming.HasValue || cloud.RecommendedTiming.Value <= 0)
+ return schedule;
+
+ var platforms = detail.Equipment
+ .Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform)
+ .ToList();
+
+ for (int i = 0; i < platforms.Count; i++)
+ {
+ var p = platforms[i];
+ var muzzleV = (float)(p.MuzzleVelocity ?? 800);
+
+ // 弹药飞行时间 ≈ 距离 / 初速(简化)
+ var dx = (float)cloud.PositionX - (float)p.PositionX;
+ var dz = (float)cloud.PositionZ - (float)p.PositionZ;
+ var dist = (float)System.Math.Sqrt(dx * dx + dz * dz);
+ var flightTime = dist / muzzleV;
+
+ // 发射时间 = 推荐云团出现时间 - 弹药飞行时间
+ var fireTime = (float)cloud.RecommendedTiming.Value - flightTime;
+ if (fireTime < 0) fireTime = 0.1f;
+
+ schedule.Add(new FireEvent
+ {
+ FireTime = fireTime,
+ PlatformIndex = i,
+ TargetX = (float)cloud.PositionX,
+ TargetY = (float)cloud.PositionY,
+ TargetZ = (float)cloud.PositionZ,
+ MuzzleVelocity = muzzleV,
+ });
+ }
+
+ return schedule;
+ }
+
+ /// 将 DefenseAdvisor 推荐方案写入想定
+ private DefenseRecommendation GetAndApplyRecommendation()
+ {
+ var detail = _scenario.GetTaskDetail(_taskId)!;
+ var threat = new ThreatProfile
+ {
+ Environment = detail.Scene,
+ Targets = detail.Targets,
+ Route = detail.Route,
+ Waypoints = detail.Waypoints,
+ };
+ var rec = new DefaultDefenseAdvisor().Recommend(threat);
+
+ _scenario.SaveCloudDispersal(_taskId, rec.Best.RecommendedCloud);
+
+ var equips = new List();
+ foreach (var d in rec.Best.Detections)
+ equips.Add(new EquipmentDeployment
+ {
+ EquipmentRole = (int)EquipmentRole.Detection,
+ Quantity = d.Quantity,
+ PositionX = d.Position.X, PositionY = d.Position.Y, PositionZ = d.Position.Z,
+ DetectionRadius = d.DetectionRadius,
+ });
+ foreach (var p in rec.Best.Platforms)
+ equips.Add(new EquipmentDeployment
+ {
+ EquipmentRole = (int)EquipmentRole.LaunchPlatform,
+ PlatformType = (int)p.Type,
+ Quantity = p.Quantity,
+ PositionX = p.Position.X, PositionY = p.Position.Y, PositionZ = p.Position.Z,
+ AerosolType = (int)rec.Best.RecommendedAerosolType,
+ MunitionCount = p.MunitionCount,
+ Cooldown = 5,
+ MuzzleVelocity = p.Type == PlatformType.GroundBased ? 800 : (double?)null,
+ });
+ _scenario.SaveDeployment(_taskId, equips);
+
+ return rec;
+ }
+
+ // ═══════════════════════════════════════════════
+ // 场景 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
+ {
+ TargetType = (int)TargetType.FixedWing,
+ PowerType = (int)PowerType.Jet,
+ Quantity = 1, TypicalSpeed = 600, TypicalAltitude = 400,
+ });
+ _scenario.SaveRoute(_taskId, new RoutePlan { FormationMode = (int)FormationMode.Single },
+ new List
+ {
+ 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());
+
+ 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
+ {
+ TargetType = (int)TargetType.Electric,
+ PowerType = (int)PowerType.Electric,
+ Quantity = 1, TypicalSpeed = 300, TypicalAltitude = 300,
+ });
+ _scenario.SaveRoute(_taskId, new RoutePlan { FormationMode = (int)FormationMode.Single },
+ new List
+ {
+ 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());
+ _scenario.SaveControlZones(_taskId, new List
+ {
+ 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:活塞发动机 → 算法推荐惰性气体 → 拦截成功
+ // ═══════════════════════════════════════════════
+
+ [Fact]
+ public void Scenario_Piston_InertGasIntercept()
+ {
+ var task = _scenario.CreateTask("活塞拦截", "");
+ _taskId = task.Id;
+
+ _scenario.SaveScene(_taskId, new CombatScene
+ {
+ WindSpeed = 3, WindDirection = (int)WindDirection.W,
+ });
+ _scenario.SaveTarget(_taskId, new TargetConfig
+ {
+ TargetType = (int)TargetType.Piston,
+ PowerType = (int)PowerType.Piston,
+ Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500,
+ });
+ _scenario.SaveRoute(_taskId, new RoutePlan { FormationMode = (int)FormationMode.Single },
+ new List
+ {
+ new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 },
+ new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 },
+ });
+
+ var rec = GetAndApplyRecommendation();
+
+ // 验证推荐参数和装备被正确持久化
+ var saved = _scenario.GetTaskDetail(_taskId)!;
+ Assert.True(saved.Cloud.RecommendedTiming.HasValue,
+ $"Piston: RecommendedTiming={saved.Cloud.RecommendedTiming}");
+ Assert.True(saved.Cloud.RecommendedTiming > 0);
+ Assert.NotEmpty(saved.Equipment.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform),
+ "应有发射平台");
+
+ Assert.Equal(AerosolType.InertGas, rec.Best.RecommendedAerosolType);
+ Assert.True(rec.Best.InterceptProbability > 0);
+ Assert.True(rec.Best.RecommendedCloud.RecommendedTiming > 0);
+
+ var eng = RunSimulation(3000);
+ var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
+ Assert.True(launched > 0, $"Piston: 应发射弹药,实际{launched}次");
+ Assert.Equal(DroneStatus.Destroyed, eng.Drones[0].Status);
+ }
+
+ // ═══════════════════════════════════════════════
+ // 场景 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
+ {
+ TargetType = (int)TargetType.HighSpeed,
+ PowerType = (int)PowerType.Jet,
+ Quantity = 1, TypicalSpeed = 500, TypicalAltitude = 800,
+ });
+ _scenario.SaveRoute(_taskId, new RoutePlan { FormationMode = (int)FormationMode.Single },
+ new List
+ {
+ new Waypoint { PosX = 0, PosY = 800, PosZ = 0, Speed = 500 },
+ new Waypoint { PosX = 30000, PosY = 800, PosZ = 0, Speed = 500 },
+ });
+
+ var rec = GetAndApplyRecommendation();
+
+ var saved = _scenario.GetTaskDetail(_taskId)!;
+ Assert.True(saved.Cloud.RecommendedTiming.HasValue,
+ $"Jet: RecommendedTiming={saved.Cloud.RecommendedTiming}");
+
+ Assert.Equal(AerosolType.ActiveMaterial, rec.Best.RecommendedAerosolType);
+ Assert.True(rec.Best.InterceptProbability > 0);
+ Assert.True(rec.Best.RecommendedCloud.RecommendedTiming > 0);
+
+ var eng = RunSimulation(3000);
+ var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
+ Assert.True(launched > 0, $"Jet: 应发射弹药,实际{launched}次");
+ Assert.Equal(DroneStatus.Destroyed, eng.Drones[0].Status);
+ }
+ }
+}
diff --git a/test/unit/CounterDrone.Core.Tests/KinematicsTests.cs b/test/unit/CounterDrone.Core.Tests/KinematicsTests.cs
new file mode 100644
index 0000000..a3731e5
--- /dev/null
+++ b/test/unit/CounterDrone.Core.Tests/KinematicsTests.cs
@@ -0,0 +1,65 @@
+using CounterDrone.Core.Algorithms;
+using CounterDrone.Core.Models;
+using Xunit;
+
+namespace CounterDrone.Core.Tests
+{
+ public class KinematicsTests
+ {
+ [Fact]
+ public void WindToVector_East_CorrectDirection()
+ {
+ var (x, y, z) = Kinematics.WindToVector(WindDirection.E, 10f);
+ Assert.True(x > 9f, "东风 → X 正方向");
+ Assert.True(System.Math.Abs(z) < 1f, "东风 → Z 接近 0");
+ Assert.Equal(0f, y, 3);
+ }
+
+ [Fact]
+ public void WindToVector_North_CorrectDirection()
+ {
+ var (x, y, z) = Kinematics.WindToVector(WindDirection.N, 10f);
+ Assert.True(System.Math.Abs(x) < 1f);
+ Assert.True(z > 9f, "北风 → Z 正方向");
+ }
+
+ [Fact]
+ public void CalculateLaunchAngle_ReturnsValidAngle()
+ {
+ var angle = Kinematics.CalculateLaunchAngle(5000f, 500f, 300f);
+ Assert.True(angle > 0);
+ Assert.True(angle < System.Math.PI / 2); // < 90°
+ }
+
+ [Fact]
+ public void CalculateLaunchAngle_CloseRange_UsesHeightConstraint()
+ {
+ // 近距离,高度约束应主导
+ var angle = Kinematics.CalculateLaunchAngle(1000f, 300f, 300f);
+ Assert.True(angle > 0.05f, "近距离也有合理发射角");
+ }
+
+ [Fact]
+ public void ParabolicPosition_T0_IsAtStart()
+ {
+ var (x, y, z) = Kinematics.ParabolicPosition(0, 0, 0, 0.5f, 0, 500f, 0);
+ Assert.Equal(0f, x, 1);
+ Assert.Equal(0f, y, 1);
+ Assert.Equal(0f, z, 1);
+ }
+
+ [Fact]
+ public void PointInPolygon_Inside_ReturnsTrue()
+ {
+ var vertices = new (float X, float Z)[] { (0, 0), (10, 0), (10, 10), (0, 10) };
+ Assert.True(Kinematics.PointInPolygon(5, 5, vertices));
+ }
+
+ [Fact]
+ public void PointInPolygon_Outside_ReturnsFalse()
+ {
+ var vertices = new (float X, float Z)[] { (0, 0), (10, 0), (10, 10), (0, 10) };
+ Assert.False(Kinematics.PointInPolygon(20, 5, vertices));
+ }
+ }
+}
diff --git a/test/unit/CounterDrone.Core.Tests/MunitionEntityTests.cs b/test/unit/CounterDrone.Core.Tests/MunitionEntityTests.cs
new file mode 100644
index 0000000..cbeb761
--- /dev/null
+++ b/test/unit/CounterDrone.Core.Tests/MunitionEntityTests.cs
@@ -0,0 +1,58 @@
+using CounterDrone.Core.Models;
+using CounterDrone.Core.Simulation;
+using Xunit;
+
+namespace CounterDrone.Core.Tests
+{
+ public class MunitionEntityTests
+ {
+ [Fact]
+ public void GroundBased_DoesNotArriveImmediately()
+ {
+ // 500 m/s, 5000m 距离, 300m 释放高度 — 可以到达
+ var m = new MunitionEntity("m1", PlatformType.GroundBased,
+ AerosolType.InertGas,
+ startX: 0, startY: 0, startZ: 0,
+ targetX: 5000, targetY: 300, targetZ: 0,
+ muzzleVelocity: 500f, releaseAltitude: 300f);
+
+ m.Update(0.05f);
+ Assert.False(m.HasArrived, "炮弹不能第一帧就到达");
+ }
+
+ [Fact]
+ public void GroundBased_ArrivesAfterPeakAndDescent()
+ {
+ var m = new MunitionEntity("m1", PlatformType.GroundBased,
+ AerosolType.InertGas,
+ startX: 0, startY: 0, startZ: 0,
+ targetX: 5000, targetY: 300, targetZ: 0,
+ muzzleVelocity: 500f, releaseAltitude: 300f);
+
+ // 500m/s 到 5000m,约需 10-15 秒
+ for (int i = 0; i < 500; i++)
+ {
+ m.Update(0.05f);
+ if (m.HasArrived) break;
+ }
+
+ Assert.True(m.HasArrived, "炮弹应在飞行后到达释放高度");
+ // 到达时高度应接近释放高度
+ Assert.True(System.Math.Abs(m.PosY - 300f) < 5f, $"PosY={m.PosY} 应接近 300");
+ }
+
+ [Fact]
+ public void GroundBased_MovesForwardOverTime()
+ {
+ var m = new MunitionEntity("m1", PlatformType.GroundBased,
+ AerosolType.InertGas,
+ startX: 0, startY: 0, startZ: 0,
+ targetX: 1000, targetY: 300, targetZ: 0,
+ muzzleVelocity: 500f, releaseAltitude: 300f);
+
+ m.Update(1f);
+ var dist = System.Math.Sqrt(m.PosX * m.PosX + m.PosZ * m.PosZ);
+ Assert.True(dist > 100f, "1秒后炮弹应水平移动 > 100m");
+ }
+ }
+}