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