- FireUnit: 新增 GunCount/ChannelsPerGun/ChannelInterval/检测设备字段 - EquipmentDeployment: 新增 GunCount/ChannelsPerGun/ChannelInterval - DefaultFireUnits: 3 种默认火力单元(标准地基/重地基/空基) - Planner: 逐通道齐射分配,ChannelInterval 错开,Cooldown 轮次 - Engine: PlatformEntity 按 GunCount×ChannelsPerGun 展开 - BuildFireUnits: 与引擎同步展开通道 - FullPipelineTests: 使用 DefaultFireUnits 配置
293 lines
13 KiB
C#
293 lines
13 KiB
C#
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using CounterDrone.Core.Algorithms;
|
||
using CounterDrone.Core.Models;
|
||
using Xunit;
|
||
|
||
namespace CounterDrone.Core.Tests
|
||
{
|
||
public class DefensePlannerTests
|
||
{
|
||
private static readonly List<AmmunitionSpec> TestAmmo = DefaultAmmunition.GetAll();
|
||
|
||
private static List<FireUnit> MakeGroundUnits(int count = 2)
|
||
{
|
||
var units = new List<FireUnit>();
|
||
for (int i = 0; i < count; i++)
|
||
units.Add(new FireUnit
|
||
{
|
||
Id = $"u{i}",
|
||
Type = PlatformType.GroundBased,
|
||
Position = new Vector3(5000 + i * 100, 0, 50),
|
||
GunCount = 4,
|
||
ChannelsPerGun = 4,
|
||
ChannelInterval = 1f,
|
||
MuzzleVelocity = 800f,
|
||
TotalMunitions = 16,
|
||
Cooldown = 5f,
|
||
AmmoTypes = new List<AerosolType> { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel },
|
||
});
|
||
return units;
|
||
}
|
||
|
||
private DroneGroup MakeThreat(PowerType powerType = PowerType.Piston, float speed = 120f)
|
||
{
|
||
return new DroneGroup
|
||
{
|
||
GroupId = "default",
|
||
Target = new TargetConfig
|
||
{
|
||
TargetType = (int)TargetType.Piston,
|
||
PowerType = (int)powerType,
|
||
Quantity = 1,
|
||
TypicalSpeed = speed,
|
||
TypicalAltitude = 500,
|
||
},
|
||
Waypoints = new List<Waypoint>
|
||
{
|
||
new Waypoint { PosX = 0, PosY = 500, PosZ = 100, Altitude = 500, Speed = speed },
|
||
new Waypoint { PosX = 10000, PosY = 500, PosZ = 100, Altitude = 500, Speed = speed },
|
||
},
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// 威胁排序
|
||
// ═══════════════════════════════════════
|
||
|
||
[Fact]
|
||
public void ThreatPriority_SpeedMatters()
|
||
{
|
||
var slow = MakeThreat(PowerType.Piston, 60);
|
||
slow.Target.TargetType = (int)TargetType.Piston;
|
||
slow.ArrivalTime = 100;
|
||
slow.ThreatIndex = CalcIndex(slow.Target);
|
||
|
||
var fast = MakeThreat(PowerType.Piston, 500);
|
||
fast.Target.TargetType = (int)TargetType.Piston;
|
||
fast.ArrivalTime = 100;
|
||
fast.ThreatIndex = CalcIndex(fast.Target);
|
||
|
||
Assert.True(fast.Priority > slow.Priority, $"fast={fast.Priority} slow={slow.Priority}");
|
||
}
|
||
|
||
[Fact]
|
||
public void ThreatPriority_TypeMatters()
|
||
{
|
||
var rotor = MakeThreat(PowerType.Electric, 200);
|
||
rotor.Target.TargetType = (int)TargetType.Rotor;
|
||
rotor.ArrivalTime = 50;
|
||
rotor.ThreatIndex = CalcIndex(rotor.Target);
|
||
|
||
var jet = MakeThreat(PowerType.Piston, 200);
|
||
jet.Target.TargetType = (int)TargetType.HighSpeed;
|
||
jet.ArrivalTime = 50;
|
||
jet.ThreatIndex = CalcIndex(jet.Target);
|
||
|
||
Assert.True(jet.ThreatIndex > rotor.ThreatIndex, $"jet={jet.ThreatIndex} rotor={rotor.ThreatIndex}");
|
||
}
|
||
|
||
private static float CalcIndex(TargetConfig t)
|
||
{
|
||
var coef = new Dictionary<TargetType, float>
|
||
{
|
||
{ TargetType.HighSpeed, 4f }, { TargetType.FixedWing, 2f },
|
||
{ TargetType.Piston, 2f }, { TargetType.Rotor, 1f }, { TargetType.Electric, 1f },
|
||
}.GetValueOrDefault((TargetType)t.TargetType, 1f);
|
||
return coef * (float)t.TypicalSpeed / 60f;
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// 弹药匹配
|
||
// ═══════════════════════════════════════
|
||
|
||
[Fact] public void Piston_InertGas() { Assert.Equal(AerosolType.InertGas, Plan(PowerType.Piston).Best.Assignments[0].AmmoType); }
|
||
[Fact] public void Jet_ActiveMaterial() { Assert.Equal(AerosolType.ActiveMaterial, Plan(PowerType.Jet, 500).Best.Assignments[0].AmmoType); }
|
||
[Fact] public void Electric_InertGas() { Assert.Equal(AerosolType.InertGas, Plan(PowerType.Electric).Best.Assignments[0].AmmoType); }
|
||
|
||
// ═══════════════════════════════════════
|
||
// 通道分配
|
||
// ═══════════════════════════════════════
|
||
|
||
[Fact]
|
||
public void SingleUnit_AllChannelsUsed_IfNeeded()
|
||
{
|
||
var result = Plan(PowerType.Piston, 60);
|
||
var events = result.Best.MergedSchedule;
|
||
Assert.True(events.Count >= 3, $"count={events.Count}");
|
||
// 通道间隔错开,但同轮无 cooldown 级延迟
|
||
float first = events.Min(e => e.FireTime);
|
||
float last = events.Max(e => e.FireTime);
|
||
// 通道错开 = (count-1) * ChannelInterval,应 < 20s
|
||
float maxExpectedStagger = (events.Count - 1) * 1f + 2f;
|
||
Assert.True(last - first < maxExpectedStagger,
|
||
$"spread={last-first:F1}s, expected<{maxExpectedStagger:F1}s");
|
||
}
|
||
|
||
[Fact]
|
||
public void NoThreats_Empty() { Assert.Equal(0, new DefaultDefensePlanner(TestAmmo).Plan(new(), new(), new CombatScene()).Best.ThreatsEngaged); }
|
||
|
||
[Fact]
|
||
public void ChannelInterval_StaggersWithinSalvo()
|
||
{
|
||
var result = Plan(PowerType.Piston, 60);
|
||
var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList();
|
||
// 同一单元通道有 ChannelInterval=1s 错开
|
||
Assert.True(events.Count >= 2);
|
||
}
|
||
|
||
[Fact]
|
||
public void MultiUnit_PoolsChannels()
|
||
{
|
||
// 2 个单元 = 32 通道
|
||
var result = new DefaultDefensePlanner(TestAmmo).Plan(
|
||
MakeGroundUnits(2), new() { MakeThreat(PowerType.Piston, 200) }, new CombatScene());
|
||
Assert.True(result.Best.ThreatsEngaged > 0);
|
||
Assert.True(result.Best.MergedSchedule.Count > 1);
|
||
}
|
||
|
||
[Fact]
|
||
public void OneSalvo_NoCooldownNeeded()
|
||
{
|
||
// 16 通道足够覆盖慢速目标
|
||
var result = Plan(PowerType.Piston, 60);
|
||
var events = result.Best.MergedSchedule;
|
||
float first = events.Min(e => e.FireTime);
|
||
float last = events.Max(e => e.FireTime);
|
||
// 最后一个事件 - 第一个事件 < Cooldown → 同一轮
|
||
Assert.True(last - first < 5f, $"spread={last - first:F1}s, should be < 5s cooldown");
|
||
}
|
||
|
||
[Fact]
|
||
public void TotalMunitions_CapsFirepower()
|
||
{
|
||
var units = new List<FireUnit>
|
||
{
|
||
new FireUnit { Id = "u0", Type = PlatformType.GroundBased,
|
||
Position = new Vector3(5000, 0, 50), GunCount = 4, ChannelsPerGun = 4,
|
||
MuzzleVelocity = 800, TotalMunitions = 2, // 只够打 2 发
|
||
AmmoTypes = new() { AerosolType.InertGas } },
|
||
};
|
||
var result = new DefaultDefensePlanner(TestAmmo).Plan(
|
||
units, new() { MakeThreat(PowerType.Piston, 200) }, new CombatScene());
|
||
Assert.True(result.Best.ThreatsEngaged > 0);
|
||
Assert.True(result.Best.Assignments.Sum(a => a.RoundsFired) <= 2);
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// 过滤与边界
|
||
// ═══════════════════════════════════════
|
||
|
||
[Fact]
|
||
public void IncompatibleAmmo_NotEngaged()
|
||
{
|
||
var units = new List<FireUnit>
|
||
{
|
||
new FireUnit { Id = "u0", Type = PlatformType.GroundBased,
|
||
Position = new Vector3(5000, 0, 50), GunCount = 4, ChannelsPerGun = 4,
|
||
MuzzleVelocity = 800, TotalMunitions = 16,
|
||
AmmoTypes = new() { AerosolType.ActiveMaterial } },
|
||
};
|
||
var result = new DefaultDefensePlanner(TestAmmo).Plan(
|
||
units, new() { MakeThreat(PowerType.Piston) }, new CombatScene());
|
||
Assert.Equal(0, result.Best.ThreatsEngaged);
|
||
}
|
||
|
||
[Fact]
|
||
public void NoUnits_Unengaged() { Assert.Equal(1, Plan(unitCount: 0).Best.ThreatsUnengaged); }
|
||
|
||
[Fact]
|
||
public void FireTime_Positive()
|
||
{
|
||
var result = Plan(PowerType.Piston, 60);
|
||
Assert.NotEmpty(result.Best.MergedSchedule);
|
||
Assert.All(result.Best.MergedSchedule, fe => Assert.True(fe.FireTime > 0));
|
||
}
|
||
|
||
[Fact]
|
||
public void FireTime_BeforeArrival()
|
||
{
|
||
var threat = MakeThreat(PowerType.Piston, 120);
|
||
var result = new DefaultDefensePlanner(TestAmmo).Plan(
|
||
MakeGroundUnits(1), new() { threat }, new CombatScene());
|
||
float arrival = threat.GetArrivalTime();
|
||
Assert.All(result.Best.MergedSchedule, fe =>
|
||
Assert.True(fe.FireTime < arrival, $"FireTime={fe.FireTime:F0} >= {arrival:F0}"));
|
||
}
|
||
|
||
[Fact]
|
||
public void MultiThreat_BothEngaged()
|
||
{
|
||
var threats = new List<DroneGroup> { MakeThreat(PowerType.Piston, 120), MakeThreat(PowerType.Jet, 300) };
|
||
threats[0].GroupId = "g0"; threats[1].GroupId = "g1";
|
||
var result = new DefaultDefensePlanner(TestAmmo).Plan(MakeGroundUnits(2), threats, new CombatScene());
|
||
Assert.Equal(2, result.Best.ThreatsEngaged);
|
||
}
|
||
|
||
[Fact]
|
||
public void Critical_HasAssignments()
|
||
{
|
||
var result = Plan(PowerType.Piston, 120);
|
||
Assert.True(result.Critical.Assignments.Count > 0);
|
||
Assert.True(result.Critical.OverallProbability >= 0.4f);
|
||
}
|
||
|
||
[Fact]
|
||
public void Best_ProbabilityHigherThanCritical()
|
||
{
|
||
var result = Plan(PowerType.Piston, 120);
|
||
Assert.True(result.Best.OverallProbability >= result.Critical.OverallProbability,
|
||
$"Best={result.Best.OverallProbability:P0} Critical={result.Critical.OverallProbability:P0}");
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// 空基
|
||
// ═══════════════════════════════════════
|
||
|
||
[Fact]
|
||
public void AirBased_Engaged()
|
||
{
|
||
var units = new List<FireUnit>
|
||
{
|
||
new FireUnit { Id = "u0", Type = PlatformType.AirBased,
|
||
Position = new Vector3(3000, 2500, -2000), GunCount = 4, ChannelsPerGun = 4,
|
||
CruiseSpeed = 55, ReleaseAltitude = 1500, TotalMunitions = 16,
|
||
AmmoTypes = new() { AerosolType.InertGas } },
|
||
};
|
||
var result = new DefaultDefensePlanner(TestAmmo).Plan(units, new() { MakeThreat() }, new CombatScene());
|
||
Assert.True(result.Best.ThreatsEngaged > 0);
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// 集成验证
|
||
// ═══════════════════════════════════════
|
||
|
||
[Fact]
|
||
public void FullPipeline_Piston_VerifiesRoundAllocation()
|
||
{
|
||
var units = MakeGroundUnits(1); // 1 单元 16 通道
|
||
var threat = MakeThreat(PowerType.Piston, 200);
|
||
threat.Waypoints.Clear();
|
||
threat.Waypoints.Add(new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 });
|
||
threat.Waypoints.Add(new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 });
|
||
var result = new DefaultDefensePlanner(TestAmmo).Plan(units, new() { threat }, new CombatScene { WindSpeed = 3 });
|
||
Assert.True(result.Best.MergedSchedule.Count >= 8,
|
||
$"只有 {result.Best.MergedSchedule.Count} 发,预期 >= 8");
|
||
Assert.All(result.Best.MergedSchedule, fe =>
|
||
Assert.True(fe.PlatformIndex >= 0 && fe.PlatformIndex < units.Count));
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// helpers
|
||
// ═══════════════════════════════════════
|
||
|
||
private PlannerResult Plan(PowerType power = PowerType.Piston, float speed = 120f, int unitCount = 1)
|
||
{
|
||
return new DefaultDefensePlanner(TestAmmo).Plan(
|
||
unitCount > 0 ? MakeGroundUnits(unitCount) : new List<FireUnit>(),
|
||
new() { MakeThreat(power, speed) },
|
||
new CombatScene());
|
||
}
|
||
}
|
||
}
|