diff --git a/docs/对接文档_Unity前端.md b/docs/对接文档_Unity前端.md
index d4475d8..894a774 100644
--- a/docs/对接文档_Unity前端.md
+++ b/docs/对接文档_Unity前端.md
@@ -324,8 +324,37 @@ public void DeleteWaypoint(string id);
// Misc
public void UpdateStep(string scenarioId, int step);
public EnumMetadata GetEnums(); // 枚举中英文对照(下拉框数据源)
+
+// 防御推荐(配置阶段调用,仿真前生成最优抛撒参数)
+public DefenseRecommendation GetDefenseRecommendation(string scenarioId);
```
+**防御推荐用法**("一键应用"功能):
+
+```csharp
+var rec = scenario.GetDefenseRecommendation(scenarioId);
+if (rec.Success)
+{
+ // rec.Best.PosX/Y/Z — 推荐抛撒位置
+ // rec.Best.Timing — 推荐抛撒时机(仿真秒)
+ // rec.Best.SalvoRounds — 推荐弹药数
+ // rec.Best.EstimatedProbability — 估计拦截概率(0~1)
+ // rec.Best.ThreatsEngaged / ThreatsUnengaged — 已拦截/未拦截威胁数
+
+ // 用户点"一键应用" → 写入 CloudDispersal
+ var cloud = scenario.GetCloud(scenarioId);
+ cloud.PositionX = rec.Best.PosX;
+ cloud.PositionY = rec.Best.PosY;
+ cloud.PositionZ = rec.Best.PosZ;
+ cloud.Source = "Algorithm";
+ cloud.PositionMode = (int)PositionMode.AlgorithmRecommended;
+ scenario.SaveCloud(scenarioId, cloud);
+}
+else
+{
+ Debug.Log($"推荐失败: {rec.Summary}");
+}
+
### IDataService(通过 `ScenarioManager.DataService` 访问)
7 类基础数据,每类 4 个方法(`GetAll*` / `Get*(id)` / `Save*(spec)` / `Delete*(id)`):
@@ -589,6 +618,26 @@ public class EnumMetadata
| `Content` | string | Markdown 报告文本(快速展示用) |
| `ReportDataJson` | string? | 结构化数据 JSON(PDF 导出用) |
+### DefenseRecommendation(防御推荐方案)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `Success` | bool | 是否规划成功 |
+| `Summary` | string | 方案摘要(失败时含原因) |
+| `Best` | RecommendOption | 最佳推荐参数 |
+
+### RecommendOption(推荐参数)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `PosX/Y/Z` | double | 推荐抛撒位置(Y=高度,米) |
+| `Timing` | double | 推荐抛撒时机(仿真秒) |
+| `SalvoRounds` | int | 推荐弹药数(齐射发数) |
+| `SalvoSpacing` | double | 云团展开间距(米) |
+| `EstimatedProbability` | double | 估计拦截概率(0~1) |
+| `ThreatsEngaged` | int | 已拦截威胁数 |
+| `ThreatsUnengaged` | int | 未拦截威胁数 |
+
---
## 八、运行时实体属性
diff --git a/src/CounterDrone.Core/Services/DefenseRecommendation.cs b/src/CounterDrone.Core/Services/DefenseRecommendation.cs
new file mode 100644
index 0000000..ebe5fa0
--- /dev/null
+++ b/src/CounterDrone.Core/Services/DefenseRecommendation.cs
@@ -0,0 +1,46 @@
+namespace CounterDrone.Core.Services
+{
+ /// 防御推荐方案 — 配置阶段调用 planner 生成,供前端"一键应用"
+ public class DefenseRecommendation
+ {
+ /// 最佳推荐参数(拦截概率最高的抛撒参数)
+ public RecommendOption Best { get; set; } = new();
+
+ /// 方案摘要(含失败原因,如有)
+ public string Summary { get; set; } = "";
+
+ /// 是否规划成功
+ public bool Success { get; set; }
+ }
+
+ /// 推荐参数档位
+ public class RecommendOption
+ {
+ /// 推荐抛撒位置 X(米)
+ public double PosX { get; set; }
+
+ /// 推荐抛撒位置 Y(米,高度)
+ public double PosY { get; set; }
+
+ /// 推荐抛撒位置 Z(米)
+ public double PosZ { get; set; }
+
+ /// 推荐抛撒时机(仿真秒)
+ public double Timing { get; set; }
+
+ /// 推荐弹药数(齐射发数)
+ public int SalvoRounds { get; set; }
+
+ /// 云团展开间距(米)
+ public double SalvoSpacing { get; set; }
+
+ /// 估计拦截概率(0~1)
+ public double EstimatedProbability { get; set; }
+
+ /// 已拦截威胁数
+ public int ThreatsEngaged { get; set; }
+
+ /// 未拦截威胁数
+ public int ThreatsUnengaged { get; set; }
+ }
+}
diff --git a/src/CounterDrone.Core/Services/IScenarioService.cs b/src/CounterDrone.Core/Services/IScenarioService.cs
index 0d8fcdd..63ab731 100644
--- a/src/CounterDrone.Core/Services/IScenarioService.cs
+++ b/src/CounterDrone.Core/Services/IScenarioService.cs
@@ -59,5 +59,8 @@ namespace CounterDrone.Core.Services
/// 返回所有枚举定义(供前端下拉框使用)
EnumMetadata GetEnums();
+
+ /// 生成防御推荐方案(配置阶段调用,仿真前基于已配置参数计算最佳/最危险抛撒参数)
+ DefenseRecommendation GetDefenseRecommendation(string scenarioId);
}
}
diff --git a/src/CounterDrone.Core/Services/ScenarioService.cs b/src/CounterDrone.Core/Services/ScenarioService.cs
index ea7bd2b..5c855c9 100644
--- a/src/CounterDrone.Core/Services/ScenarioService.cs
+++ b/src/CounterDrone.Core/Services/ScenarioService.cs
@@ -2,8 +2,10 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
+using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using CounterDrone.Core.Repository;
+using SQLite;
namespace CounterDrone.Core.Services
{
@@ -17,6 +19,8 @@ namespace CounterDrone.Core.Services
private readonly CloudDispersalRepository _cloudRepo;
private readonly RoutePlanRepository _routeRepo;
private readonly WaypointRepository _waypointRepo;
+ private readonly IPathProvider? _paths;
+ private readonly IDefensePlanner? _planner;
public ScenarioService(
ScenarioRepository scenarioRepo,
@@ -26,7 +30,9 @@ namespace CounterDrone.Core.Services
ScenarioUnitRepository equipRepo,
CloudDispersalRepository cloudRepo,
RoutePlanRepository routeRepo,
- WaypointRepository waypointRepo)
+ WaypointRepository waypointRepo,
+ IPathProvider? paths = null,
+ IDefensePlanner? planner = null)
{
_scenarioRepo = scenarioRepo;
_sceneRepo = sceneRepo;
@@ -36,6 +42,8 @@ namespace CounterDrone.Core.Services
_cloudRepo = cloudRepo;
_routeRepo = routeRepo;
_waypointRepo = waypointRepo;
+ _paths = paths;
+ _planner = planner;
}
// ========== scenario CRUD ==========
@@ -479,6 +487,157 @@ namespace CounterDrone.Core.Services
private static int _counter;
+ // ========== Defense Recommendation ==========
+
+ public DefenseRecommendation GetDefenseRecommendation(string scenarioId)
+ {
+ if (_paths == null || _planner == null)
+ throw new InvalidOperationException("ScenarioService 未配置 IPathProvider/IDefensePlanner,无法生成推荐方案");
+
+ var config = GetScenarioDetail(scenarioId);
+ if (config == null)
+ throw new ArgumentException($"想定不存在: {scenarioId}");
+
+ // 加载 spec 数据
+ Dictionary droneSpecs;
+ Dictionary platformSpecs;
+ Dictionary sensorSpecs;
+ List ammoCatalog;
+ using (var db = new SQLiteConnection(_paths.GetMainDbPath()))
+ {
+ droneSpecs = db.Table().ToDictionary(d => d.Id);
+ platformSpecs = db.Table().ToDictionary(f => f.Id);
+ sensorSpecs = db.Table().ToDictionary(s => s.Id);
+ ammoCatalog = db.Table().ToList();
+ }
+
+ var fireUnits = BuildFireUnits(config, platformSpecs);
+ var threats = BuildDroneWaves(config, droneSpecs);
+ var detectionSources = BuildDetectionSources(config, sensorSpecs);
+
+ if (fireUnits.Count == 0 || threats.Count == 0)
+ return new DefenseRecommendation
+ {
+ Success = false,
+ Summary = "缺少火力单元或威胁目标,无法生成推荐方案",
+ };
+
+ var result = _planner.Plan(fireUnits, threats, config.Scene, detectionSources);
+
+ if (result.Best.MergedSchedule.Count == 0)
+ return new DefenseRecommendation
+ {
+ Success = false,
+ Summary = result.Best.Summary,
+ };
+
+ return new DefenseRecommendation
+ {
+ Success = true,
+ Summary = result.Best.Summary,
+ Best = PlanToOption(result.Best),
+ };
+ }
+
+ private static RecommendOption PlanToOption(DefensePlan plan)
+ {
+ if (plan.MergedSchedule.Count == 0)
+ return new RecommendOption();
+
+ // 取首发作为代表抛撒点
+ var first = plan.MergedSchedule.OrderBy(f => f.FireTime).First();
+ return new RecommendOption
+ {
+ PosX = first.TargetX,
+ PosY = first.TargetY,
+ PosZ = first.TargetZ,
+ Timing = first.FireTime,
+ SalvoRounds = plan.MergedSchedule.Count,
+ SalvoSpacing = 0, // spacing 在 planner 内部计算,不暴露到 DefensePlan
+ EstimatedProbability = plan.OverallProbability,
+ ThreatsEngaged = plan.ThreatsEngaged,
+ ThreatsUnengaged = plan.ThreatsUnengaged,
+ };
+ }
+
+ private static List BuildFireUnits(ScenarioConfig config, Dictionary specs)
+ {
+ var units = new List();
+ foreach (var eq in config.Units.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform))
+ {
+ if (!specs.TryGetValue(eq.LaunchPlatformSpecId, out var spec)) continue;
+ int qty = Math.Max(1, eq.Quantity);
+ for (int i = 0; i < qty; i++)
+ {
+ units.Add(new FireUnit
+ {
+ Id = $"fu{units.Count}",
+ Type = (PlatformType)spec.PlatformType,
+ Position = new Vector3((float)eq.PositionX + i * 50, (float)eq.PositionY, (float)eq.PositionZ),
+ GunCount = spec.GunCount,
+ ChannelsPerGun = spec.ChannelsPerGun,
+ ChannelInterval = (float)spec.ChannelInterval,
+ CruiseSpeed = (float)spec.CruiseSpeed,
+ ReleaseAltitude = (float)spec.ReleaseAltitude,
+ MuzzleVelocity = (float)spec.MuzzleVelocity,
+ TotalMunitions = eq.MunitionCount ?? spec.GunCount * spec.ChannelsPerGun,
+ AmmoTypes = new List { (AerosolType)(eq.AerosolType ?? 0) },
+ Cooldown = (float)spec.Cooldown,
+ });
+ }
+ }
+ return units;
+ }
+
+ private static List BuildDroneWaves(ScenarioConfig config, Dictionary specs)
+ {
+ var waves = new List();
+ foreach (var t in config.Drones)
+ {
+ if (!specs.TryGetValue(t.DroneSpecId, out var droneSpec)) continue;
+ var route = config.Routes.FirstOrDefault(r => r.WaveId == t.WaveId);
+ var wps = config.WaypointGroups.GetValueOrDefault(t.WaveId, new List());
+ waves.Add(new DroneWave
+ {
+ WaveId = t.WaveId,
+ Profile = droneSpec,
+ Quantity = t.Quantity,
+ Route = route ?? new RoutePlan(),
+ Waypoints = wps,
+ });
+ }
+ return waves;
+ }
+
+ private static List BuildDetectionSources(ScenarioConfig config, Dictionary sensorSpecs)
+ {
+ var sources = new List();
+ foreach (var eq in config.Units)
+ {
+ if (string.IsNullOrEmpty(eq.SensorSpecId)) continue;
+ if (!sensorSpecs.TryGetValue(eq.SensorSpecId, out var sensor)) continue;
+ bool hasDetection = sensor.RadarRange > 0 || sensor.EORange > 0 || sensor.IRRange > 0;
+ if (!hasDetection) continue;
+ int qty = Math.Max(1, eq.Quantity);
+ for (int i = 0; i < qty; i++)
+ {
+ sources.Add(new DetectionSource
+ {
+ Position = new Vector3((float)eq.PositionX + i * 50, (float)eq.PositionY, (float)eq.PositionZ),
+ RadarRange = (float)sensor.RadarRange,
+ EORange = (float)sensor.EORange,
+ IRRange = (float)sensor.IRRange,
+ Accuracy = (float)sensor.Accuracy,
+ MinElevation = (float)(sensor.MinElevation ?? float.MaxValue),
+ MaxElevation = (float)(sensor.MaxElevation ?? float.MaxValue),
+ MinDetectAlt = (float)(sensor.MinDetectAlt ?? float.MaxValue),
+ MaxDetectAlt = (float)(sensor.MaxDetectAlt ?? float.MaxValue),
+ });
+ }
+ }
+ return sources;
+ }
+
public static string GenerateScenarioNumber()
{
var now = DateTime.Now;
diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll
index 01df16f..1873c29 100644
Binary files a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll and b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll differ
diff --git a/src/Unity/Assets/Scripts/Managers/ScenarioManager.cs b/src/Unity/Assets/Scripts/Managers/ScenarioManager.cs
index 60367ef..680413a 100644
--- a/src/Unity/Assets/Scripts/Managers/ScenarioManager.cs
+++ b/src/Unity/Assets/Scripts/Managers/ScenarioManager.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using CounterDrone.Core;
+using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using CounterDrone.Core.Repository;
using CounterDrone.Core.Services;
@@ -25,11 +26,15 @@ namespace CounterDrone.Unity
Debug.Log($"ScenarioManager.Awake: dbPath={paths.GetMainDbPath()}");
_db = new DatabaseManager(paths).OpenMainDb();
SqliteConnectionTracker.Track(_db);
+ var planner = new DefensePlanner(
+ DefaultData.Load(paths).Ammunition,
+ PlannerConfig.Load(paths));
_service = new ScenarioService(
new ScenarioRepository(_db), new CombatSceneRepository(_db),
new ControlZoneRepository(_db), new ScenarioDroneRepository(_db),
new ScenarioUnitRepository(_db), new CloudDispersalRepository(_db),
- new RoutePlanRepository(_db), new WaypointRepository(_db));
+ new RoutePlanRepository(_db), new WaypointRepository(_db),
+ paths, planner);
_dataService = new DataService(
new AmmunitionSpecRepository(_db), new FireUnitSpecRepository(_db),
new DroneSpecRepository(_db), new SensorSpecRepository(_db),
@@ -90,6 +95,10 @@ namespace CounterDrone.Unity
public EnumMetadata GetEnums() => _service.GetEnums();
+ /// 生成防御推荐方案(配置阶段调用,返回最佳值/最危险值供前端"一键应用")
+ public DefenseRecommendation GetDefenseRecommendation(string scenarioId)
+ => _service.GetDefenseRecommendation(scenarioId);
+
[ContextMenu("Verify")]
void Verify()
{
diff --git a/test/unit/CounterDrone.Core.Tests/DefenseRecommendationTests.cs b/test/unit/CounterDrone.Core.Tests/DefenseRecommendationTests.cs
new file mode 100644
index 0000000..5044818
--- /dev/null
+++ b/test/unit/CounterDrone.Core.Tests/DefenseRecommendationTests.cs
@@ -0,0 +1,97 @@
+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 SQLite;
+using Xunit;
+
+namespace CounterDrone.Core.Tests
+{
+ public class DefenseRecommendationTests : IDisposable
+ {
+ private readonly string _testDir;
+ private readonly IPathProvider _paths;
+ private readonly SQLiteConnection _db;
+ private readonly ScenarioService _scenario;
+ private string _scenarioId = string.Empty;
+
+ public DefenseRecommendationTests()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), $"cd_rec_{Guid.NewGuid():N}");
+ _paths = new TestPathProvider(_testDir);
+ var dbm = new DatabaseManager(_paths);
+ _db = dbm.OpenMainDb();
+
+ var planner = new DefensePlanner(
+ DefaultData.Load(_paths).Ammunition,
+ PlannerConfig.Load(_paths));
+ _scenario = new ScenarioService(
+ new ScenarioRepository(_db), new CombatSceneRepository(_db),
+ new ControlZoneRepository(_db), new ScenarioDroneRepository(_db),
+ new ScenarioUnitRepository(_db), new CloudDispersalRepository(_db),
+ new RoutePlanRepository(_db), new WaypointRepository(_db),
+ _paths, planner);
+ }
+
+ public void Dispose()
+ {
+ _db?.Close();
+ if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true);
+ }
+
+ [Fact]
+ public void GetDefenseRecommendation_PresetScenario_ReturnsBest()
+ {
+ // 用预设想定
+ var results = _scenario.SearchScenarios("活塞", null, null, 1, 10);
+ Assert.True(results.TotalCount > 0, "预设想定未找到");
+ _scenarioId = results.Items[0].Id;
+
+ var rec = _scenario.GetDefenseRecommendation(_scenarioId);
+
+ Assert.True(rec.Success, $"推荐失败: {rec.Summary}");
+ Assert.True(rec.Best.SalvoRounds > 0, "最佳方案弹药数应 > 0");
+ Assert.True(rec.Best.EstimatedProbability > 0, "最佳方案概率应 > 0");
+ Assert.NotEmpty(rec.Summary);
+ }
+
+ [Fact]
+ public void GetDefenseRecommendation_NoFireUnits_ReturnsFailure()
+ {
+ // 创建无火力单元的想定
+ var sc = _scenario.CreateScenario("无防御测试", "");
+ _scenarioId = sc.Id;
+ _scenario.SaveScene(_scenarioId, new CombatScene { WindSpeed = 0, Visibility = 10000 });
+ _scenario.SaveScenarioDrone(_scenarioId, TestData.CreateScenarioDrone("default", 1));
+ _scenario.SaveRoute(_scenarioId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
+ new List {
+ new() { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 },
+ new() { PosX = 10000, PosY = 500, PosZ = 0, Speed = 200 },
+ });
+ _scenario.SaveCloudDispersal(_scenarioId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas });
+
+ var rec = _scenario.GetDefenseRecommendation(_scenarioId);
+
+ Assert.False(rec.Success);
+ Assert.Contains("火力单元", rec.Summary);
+ }
+
+ [Fact]
+ public void GetDefenseRecommendation_NoPlanner_Throws()
+ {
+ // 不传 planner 的 ScenarioService
+ var svcNoPlanner = new ScenarioService(
+ new ScenarioRepository(_db), new CombatSceneRepository(_db),
+ new ControlZoneRepository(_db), new ScenarioDroneRepository(_db),
+ new ScenarioUnitRepository(_db), new CloudDispersalRepository(_db),
+ new RoutePlanRepository(_db), new WaypointRepository(_db));
+
+ Assert.Throws(() => svcNoPlanner.GetDefenseRecommendation("any"));
+ }
+ }
+}
diff --git a/unity_plugins/CounterDrone.Core.dll b/unity_plugins/CounterDrone.Core.dll
index 01df16f..1873c29 100644
Binary files a/unity_plugins/CounterDrone.Core.dll and b/unity_plugins/CounterDrone.Core.dll differ