0df225a2de
Co-authored-by: Codex <codex@openai.com>
1104 lines
50 KiB
Rust
1104 lines
50 KiB
Rust
//! Raids to order: what flies (kinds and numbers), from where, and at what.
|
|
//! The smart enemy plans: it knows where the systems are and what they see,
|
|
//! sends decoys first to draw their missiles, sends Lancets at the guns and
|
|
//! fast missiles at the long-range systems, routes its drones round the
|
|
//! systems' reach and low under their radars (through the dead zones and
|
|
//! behind hills), and times every launch so that all of it arrives at once.
|
|
use super::*;
|
|
use std::collections::BinaryHeap;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Approach {
|
|
/// From one sector, ±20°.
|
|
Sector,
|
|
/// From two sectors far apart.
|
|
TwoSectors,
|
|
/// From every side.
|
|
AllSides,
|
|
/// From one sector, a kind at a time, 15 s apart.
|
|
Waves,
|
|
}
|
|
|
|
impl Approach {
|
|
pub const ALL: [Approach; 4] = [Approach::Sector, Approach::TwoSectors, Approach::AllSides, Approach::Waves];
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
Approach::Sector => "один сектор",
|
|
Approach::TwoSectors => "два сектора",
|
|
Approach::AllSides => "со всех сторон",
|
|
Approach::Waves => "волнами",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Aim {
|
|
/// The marked objects.
|
|
Objects,
|
|
/// The objects, and a third of the drones at air defence.
|
|
ObjectsAndSams,
|
|
/// The planner chooses targets and routes, and times the strike.
|
|
Smart,
|
|
}
|
|
|
|
impl Aim {
|
|
pub const ALL: [Aim; 3] = [Aim::Objects, Aim::ObjectsAndSams, Aim::Smart];
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
Aim::Objects => "только объекты",
|
|
Aim::ObjectsAndSams => "объекты и ПВО",
|
|
Aim::Smart => "умный противник",
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct RaidOrder {
|
|
pub kinds: Vec<(DroneKind, u32)>,
|
|
pub approach: Approach,
|
|
/// How far out the drones start (ballistic missiles from their own range).
|
|
pub dist: f64,
|
|
pub aim: Aim,
|
|
}
|
|
|
|
impl RaidOrder {
|
|
/// A combined strike of about `n`, as `/airdef wave mix n` orders it.
|
|
pub fn mix(n: u32, dist: f64) -> RaidOrder {
|
|
let n = n.max(4);
|
|
let mut kinds = vec![
|
|
(DroneKind::Gerbera, n / 4 + 1),
|
|
(DroneKind::Shahed, n / 3 + 1),
|
|
(DroneKind::Geran3, n / 8 + 1),
|
|
(DroneKind::Kh101, n / 8 + 1),
|
|
(DroneKind::Kalibr, n / 10 + 1),
|
|
(DroneKind::Kh22, n / 16),
|
|
(DroneKind::Zircon, n / 20),
|
|
(DroneKind::Iskander, n / 12 + 1),
|
|
(DroneKind::Kinzhal, n / 16),
|
|
];
|
|
if n >= 30 {
|
|
kinds.push((DroneKind::Oreshnik, 1));
|
|
}
|
|
kinds.retain(|k| k.1 > 0);
|
|
RaidOrder { kinds, approach: Approach::TwoSectors, dist, aim: Aim::Objects }
|
|
}
|
|
}
|
|
|
|
/// A drone of a raid being planned.
|
|
struct Item {
|
|
d: Drone,
|
|
from: DVec3,
|
|
aim: DVec3,
|
|
/// Its route's key (kind, bearing, target), when it gets one.
|
|
key: Option<(DroneKind, i32, i32, i32)>,
|
|
early: f64,
|
|
gap: f64,
|
|
/// The site it is a job of: the want (see `sites`) and its pad there.
|
|
want: usize,
|
|
pad: u32,
|
|
}
|
|
|
|
/// A raid being planned: its drones, the prices and routes so far, and the planned ones.
|
|
pub(super) struct Plan {
|
|
grid: Grid,
|
|
prices: HashMap<DroneKind, Vec<f32>>,
|
|
routes: HashMap<(DroneKind, i32, i32, i32), Vec<DVec3>>,
|
|
items: Vec<Item>,
|
|
next: usize,
|
|
/// (drone, flight time, how early it arrives, its wave's delay, its site)
|
|
done: Vec<(Drone, f64, f64, f64, u32)>,
|
|
/// The launch sites wanted, and whether they are settled; the ticks spent waiting for the game's answers.
|
|
wants: Vec<sites::Want>,
|
|
sited: bool,
|
|
site_wait: u32,
|
|
/// The player has been told the enemy is still preparing.
|
|
said: bool,
|
|
smart: bool,
|
|
approach: Approach,
|
|
total: u32,
|
|
centre: DVec3,
|
|
/// The land under the grid's cells (NaN where the game has none to tell of), the cells still unanswered, and
|
|
/// the ticks the planner has waited for the answers. A smart raid is routed over the land, not round it.
|
|
heights: Vec<f32>,
|
|
unanswered: Vec<u32>,
|
|
waited: u32,
|
|
mapped: bool,
|
|
}
|
|
|
|
/// The raid in progress: the counts at its start, for the report.
|
|
pub(super) struct WaveStats {
|
|
kills: u32,
|
|
hits: u32,
|
|
launched: u32,
|
|
fired: u32,
|
|
sams: usize,
|
|
objects: f64,
|
|
size: u32,
|
|
tally: record::Tally,
|
|
}
|
|
|
|
/// The planner's grid: cells this big, at most this many a side.
|
|
pub(super) const CELL: f64 = 16.0;
|
|
pub(super) const CELLS: i32 = 140;
|
|
/// The planner's work a tick (cells priced and opened): a raid takes a few ticks to plan, never a long one.
|
|
/// The longest a drone of a raid that arrives together waits to take off (ticks): a Shahed at 0.6 blocks a tick
|
|
/// takes 6700 of them to fly 4000 blocks, and the fastest missile must wait for it.
|
|
const LONGEST_WAIT: f64 = 12_000.0;
|
|
pub(super) const WORK_PER_TICK: usize = 40_000;
|
|
|
|
/// The planner's grid over a raid: its corner, its cells' size and how many a side.
|
|
pub(super) struct Grid {
|
|
pub(super) min: DVec3,
|
|
pub(super) cell: f64,
|
|
pub(super) size: i32,
|
|
}
|
|
|
|
impl Grid {
|
|
pub(super) fn index(&self, p: DVec3) -> (i32, i32) {
|
|
let n = self.size;
|
|
((((p.x - self.min.x) / self.cell) as i32).clamp(0, n - 1), (((p.z - self.min.z) / self.cell) as i32).clamp(0, n - 1))
|
|
}
|
|
fn centre(&self, (i, j): (i32, i32), y: f64) -> DVec3 {
|
|
DVec3::new(self.min.x + (i as f64 + 0.5) * self.cell, y, self.min.z + (j as f64 + 0.5) * self.cell)
|
|
}
|
|
}
|
|
|
|
/// The cheapest way over `grid` from `from` to `to` by its prices (A*): points along it, `to` last.
|
|
pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
|
|
plan_route_on(grid, prices, None, from, to)
|
|
}
|
|
|
|
/// The land a route crosses: its height under each cell (NaN unknown), the climb a kind holds for long
|
|
/// (rise a block), and the height above which the land forces a drone up from its low altitude.
|
|
pub(super) struct Relief<'a> {
|
|
pub heights: &'a [f32],
|
|
pub slope: f32,
|
|
pub floor: f32,
|
|
}
|
|
|
|
/// [`plan_route`] over relief: a climb steeper than the kind holds is dear (a cliff or a ridge it would have to
|
|
/// pull up over), and high land costs more than a valley, where it can fly low and stay under the radars.
|
|
pub(super) fn plan_route_on(grid: &Grid, prices: &[f32], relief: Option<&Relief>, from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
|
|
let mut opened = 0;
|
|
let n = grid.size;
|
|
let at = |(i, j): (i32, i32)| (i * n + j) as usize;
|
|
let start = grid.index(from);
|
|
let goal = grid.index(to);
|
|
let mut best = vec![f32::INFINITY; (n * n) as usize];
|
|
let mut came = vec![u32::MAX; (n * n) as usize];
|
|
let mut open = BinaryHeap::new();
|
|
best[at(start)] = 0.0;
|
|
open.push(Open(0.0, start));
|
|
let h = |(i, j): (i32, i32)| (((i - goal.0) as f64).powi(2) + ((j - goal.1) as f64).powi(2)).sqrt();
|
|
while let Some(Open(f, c)) = open.pop() {
|
|
if c == goal {
|
|
break;
|
|
}
|
|
opened += 1;
|
|
let g = best[at(c)];
|
|
// A stale entry: this cell was reached cheaper since.
|
|
if f > f64::from(g) + h(c) + 1e-3 {
|
|
continue;
|
|
}
|
|
for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)] {
|
|
let nb = (c.0 + di, c.1 + dj);
|
|
if nb.0 < 0 || nb.1 < 0 || nb.0 >= n || nb.1 >= n {
|
|
continue;
|
|
}
|
|
let len = if di != 0 && dj != 0 { std::f32::consts::SQRT_2 } else { 1.0 };
|
|
// The target's own cell is where it must go, whatever covers it.
|
|
let mut price = if nb == goal { len } else { len * prices[at(nb)] };
|
|
if let Some(r) = relief
|
|
&& nb != goal
|
|
{
|
|
let (h0, h1) = (r.heights[at(c)], r.heights[at(nb)]);
|
|
if h1.is_finite() {
|
|
price += len * ((h1 - r.floor) / 30.0).clamp(0.0, 4.0);
|
|
if h0.is_finite() {
|
|
let slope = (h1 - h0) / (len * grid.cell as f32);
|
|
if slope > r.slope {
|
|
price += (slope - r.slope) * 20.0 * len;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let ng = g + price;
|
|
if ng < best[at(nb)] {
|
|
best[at(nb)] = ng;
|
|
came[at(nb)] = at(c) as u32;
|
|
open.push(Open(f64::from(ng) + h(nb), nb));
|
|
}
|
|
}
|
|
}
|
|
if came[at(goal)] == u32::MAX {
|
|
return (vec![to], opened);
|
|
}
|
|
let mut cells = vec![goal];
|
|
let mut c = at(goal);
|
|
while came[c] != u32::MAX && c != at(start) {
|
|
c = came[c] as usize;
|
|
cells.push(((c as i32) / n, (c as i32) % n));
|
|
}
|
|
cells.reverse();
|
|
let mut route: Vec<DVec3> = cells.iter().skip(2).step_by(3).map(|&c| grid.centre(c, to.y)).collect();
|
|
route.retain(|p| horizontal(*p - to).length() > grid.cell * 2.0);
|
|
route.push(to);
|
|
(route, opened)
|
|
}
|
|
|
|
/// A cell in the A* queue: the cheapest first.
|
|
#[derive(PartialEq)]
|
|
struct Open(f64, (i32, i32));
|
|
impl Eq for Open {}
|
|
impl Ord for Open {
|
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
|
other.0.total_cmp(&self.0)
|
|
}
|
|
}
|
|
impl PartialOrd for Open {
|
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
Some(self.cmp(other))
|
|
}
|
|
}
|
|
|
|
/// Ticks an air start waits for the land under it to be told of.
|
|
const AIR_START_WAIT: u32 = 120;
|
|
|
|
impl Sim {
|
|
/// Orders a raid on `objects` (at least one point). A smart raid is planned over the next ticks
|
|
/// (each route found in full, a few a tick) and launched when its plan is ready.
|
|
pub fn order_raid(&mut self, order: &RaidOrder, objects: &[DVec3], out: &mut Out) -> u32 {
|
|
let Some(¢re) = objects.first() else { return 0 };
|
|
// One raid is planned at a time: another order waits for this one's drones to be sent.
|
|
if self.plan.is_some() {
|
|
out.messages.push("Противник ещё готовит прошлый налёт: подожди пару секунд".into());
|
|
return 0;
|
|
}
|
|
let total: u32 = order.kinds.iter().map(|k| k.1).sum();
|
|
if total == 0 {
|
|
return 0;
|
|
}
|
|
let smart = order.aim == Aim::Smart;
|
|
// A smart enemy comes from where its raids got through best.
|
|
let bearing = if smart { self.best_bearing() } else { self.rng.f() * TAU };
|
|
// The raid's sides (1, 2, or 4-5): its launch sites go there (#100).
|
|
let sides = sites::pick_sides(&mut self.rng, order.approach, bearing);
|
|
let mut wants: Vec<sites::Want> = Vec::new();
|
|
// One grid over the whole raid; each kind's prices are worked out once, routes are shared
|
|
// by the drones of a kind going from the same bearing to the same target.
|
|
let span = order.dist + 450.0;
|
|
let cell = CELL.max(2.0 * span / CELLS as f64);
|
|
let grid = Grid { min: centre - DVec3::new(span, 0.0, span), cell, size: (2.0 * span / cell) as i32 + 1 };
|
|
let mut items = Vec::new();
|
|
// A smart raid goes for the radars too (a few strikers each); the others pick as they always did.
|
|
let radar_jobs = if smart { self.radar_strikes(&order.kinds) } else { Vec::new() };
|
|
let mut index = 0usize;
|
|
for (group, &(kind, count)) in order.kinds.iter().enumerate() {
|
|
let spec = kind.spec();
|
|
for _ in 0..count {
|
|
let side = match order.approach {
|
|
Approach::Sector | Approach::Waves => 0,
|
|
Approach::TwoSectors => usize::from(self.rng.chance(0.5)),
|
|
Approach::AllSides => (self.rng.f() * sides.len() as f64) as usize % sides.len(),
|
|
};
|
|
let a = sides[side];
|
|
let object = objects[(self.rng.f() * objects.len() as f64) as usize % objects.len()];
|
|
let job = radar_jobs.get(index).copied().flatten();
|
|
index += 1;
|
|
let (target, at_sam) = match job {
|
|
Some((at, id)) => (at, Some(id)),
|
|
None => self.choose_target(kind, object, order.aim),
|
|
};
|
|
let aim = target + DVec3::new(self.rng.s() * 4.0, 0.0, self.rng.s() * 4.0);
|
|
let dist = spec.ballistic.map_or(order.dist, |far| far.max(order.dist)) + self.rng.f() * 50.0;
|
|
let height = if spec.ballistic.is_some() { 10.0 } else if spec.cruise > 0.0 { spec.cruise } else { 25.0 };
|
|
// Where it starts is provisional: the site's pad, once the site is placed.
|
|
let from = centre + DVec3::new(a.sin() * dist, height + self.rng.s() * 6.0, a.cos() * dist);
|
|
let mut d = self.make_drone(kind, from, horizontal(aim - from), aim);
|
|
let range = spec.ballistic.map_or(order.dist, |far| far.max(order.dist));
|
|
let want = sites::want_index(&mut wants, centre, side, a, sites::SiteClass::of(kind), range, order.dist.max(120.0));
|
|
let pad = wants[want].jobs;
|
|
wants[want].jobs += 1;
|
|
d.sector = ew::sector(a) as u8;
|
|
let routed = smart && spec.ballistic.is_none() && !spec.decoy && !at_sam.is_some_and(|_| kind == DroneKind::Lancet);
|
|
let key = routed.then(|| (kind, (a.rem_euclid(TAU) / 0.35) as i32, (target.x / 24.0) as i32, (target.z / 24.0) as i32));
|
|
// Decoys come 15 s ahead, the rest together; "waves" one kind after another.
|
|
let early = if spec.decoy { 300.0 } else { 0.0 };
|
|
let gap = if order.approach == Approach::Waves { group as f64 * 300.0 } else { 0.0 };
|
|
items.push(Item { d, from, aim, key, early, gap, want, pad });
|
|
}
|
|
}
|
|
self.plan = Some(Plan {
|
|
grid,
|
|
prices: HashMap::new(),
|
|
routes: HashMap::new(),
|
|
items,
|
|
next: 0,
|
|
done: Vec::new(),
|
|
wants,
|
|
sited: false,
|
|
site_wait: 0,
|
|
said: false,
|
|
smart,
|
|
approach: order.approach,
|
|
total,
|
|
centre,
|
|
heights: Vec::new(),
|
|
unanswered: Vec::new(),
|
|
waited: 0,
|
|
mapped: !smart,
|
|
});
|
|
// A raid without routes is ready at once.
|
|
self.plan_step(None, out);
|
|
total
|
|
}
|
|
|
|
/// Works on the raid's plan a while (a tick's share), and launches it when it is ready.
|
|
pub(super) fn plan_step(&mut self, ground: Option<&mut Ground>, out: &mut Out) {
|
|
let Some(mut plan) = self.plan.take() else { return };
|
|
let mut ground = ground;
|
|
// First the launch sites: suitable ground the game has told of, or no raid (#100).
|
|
if !plan.sited {
|
|
let Some(g) = ground.as_deref_mut() else {
|
|
self.plan = Some(plan);
|
|
return;
|
|
};
|
|
if !self.settle_sites(&mut plan, g, out) {
|
|
if !plan.items.is_empty() {
|
|
self.plan = Some(plan);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
// A smart raid first learns the land under its grid: asked for in bulk, a thousand columns a tick, for
|
|
// up to two seconds (what the game has no land for stays unknown and costs nothing).
|
|
if !plan.mapped {
|
|
let Some(ground) = ground else {
|
|
self.plan = Some(plan);
|
|
return;
|
|
};
|
|
let n = plan.grid.size;
|
|
if plan.heights.is_empty() {
|
|
plan.heights = vec![f32::NAN; (n * n) as usize];
|
|
plan.unanswered = (0..(n * n) as u32).collect();
|
|
}
|
|
let Plan { grid, heights, unanswered, .. } = &mut plan;
|
|
unanswered.retain(|&k| {
|
|
let (i, j) = (k as i32 / n, k as i32 % n);
|
|
let c = grid.centre((i, j), 0.0);
|
|
match ground.column(c.x.floor() as i32, c.z.floor() as i32, 1024) {
|
|
None => true,
|
|
Some(h) => {
|
|
if let Some(h) = h {
|
|
heights[k as usize] = h as f32;
|
|
}
|
|
false
|
|
}
|
|
}
|
|
});
|
|
plan.waited += 1;
|
|
// The game answers 512 columns a tick: a big grid (a long range) gets the time its cells take.
|
|
if plan.unanswered.is_empty() || plan.waited >= 40 + (n * n) as u32 / 300 {
|
|
plan.mapped = true;
|
|
} else {
|
|
self.plan = Some(plan);
|
|
return;
|
|
}
|
|
}
|
|
let mut work = 0usize;
|
|
while plan.next < plan.items.len() && work < WORK_PER_TICK {
|
|
let i = plan.next;
|
|
let (kind, from, aim, key) = {
|
|
let it = &plan.items[i];
|
|
(it.d.kind, it.from, it.aim, it.key)
|
|
};
|
|
let spec = kind.spec();
|
|
let mut d = plan.items[i].d.clone();
|
|
// It starts over the land, not in it: a hill under the starting point lifts it.
|
|
let mut from = from;
|
|
if !plan.heights.is_empty() && plan.wants[plan.items[i].want].found.is_none() {
|
|
let (gi, gj) = plan.grid.index(from);
|
|
let g = plan.heights[(gi * plan.grid.size + gj) as usize];
|
|
let floor = f64::from(g) + if spec.ballistic.is_some() { 10.0 } else { spec.clearance + 10.0 };
|
|
if g.is_finite() && from.y < floor {
|
|
from.y = floor;
|
|
d.pos.y = floor;
|
|
if spec.ballistic.is_some() {
|
|
let aimed = aim + d.err;
|
|
let flat = horizontal(aimed - from);
|
|
let time = (flat.length() / spec.speed).max(30.0);
|
|
d.vel = flat / time + DVec3::Y * ((aimed.y - from.y + 0.5 * GRAVITY * time * time) / time);
|
|
}
|
|
}
|
|
}
|
|
let mut path = from.distance(aim);
|
|
if let Some(key) = key {
|
|
let route = match plan.routes.get(&key) {
|
|
Some(r) => r.clone(),
|
|
None => {
|
|
if !plan.prices.contains_key(&kind) {
|
|
let heights = (!plan.heights.is_empty()).then_some(plan.heights.as_slice());
|
|
let (p, cost) = self.price_grid_on(&plan.grid, kind, heights);
|
|
work += cost;
|
|
plan.prices.insert(kind, p);
|
|
}
|
|
let relief = (!plan.heights.is_empty()).then(|| Relief {
|
|
heights: &plan.heights,
|
|
slope: (kind.climb_pitch().tan() * 0.5) as f32,
|
|
floor: (aim.y + spec.clearance) as f32,
|
|
});
|
|
let (r, cost) = plan_route_on(&plan.grid, &plan.prices[&kind], relief.as_ref(), from, aim);
|
|
work += cost;
|
|
plan.routes.insert(key, r.clone());
|
|
r
|
|
}
|
|
};
|
|
// Each drone a little off the shared line.
|
|
d.route = route.iter().map(|p| *p + DVec3::new(self.rng.s() * 6.0, 0.0, self.rng.s() * 6.0)).collect();
|
|
d.low = matches!(spec.class, Class::Drone | Class::Jet | Class::Cruise);
|
|
path = route_length(from, &d.route, aim);
|
|
}
|
|
let time = match d.flight {
|
|
Flight::Ballistic => (horizontal(aim - from).length() / spec.speed).max(30.0),
|
|
_ => path / spec.speed,
|
|
};
|
|
plan.done.push((d, time, plan.items[i].early, plan.items[i].gap, plan.wants[plan.items[i].want].site));
|
|
plan.next += 1;
|
|
work += 1;
|
|
}
|
|
if plan.next < plan.items.len() {
|
|
self.plan = Some(plan);
|
|
return;
|
|
}
|
|
// Timing: smart raids arrive at once; others leave at once (a moment apart).
|
|
let now = self.tick;
|
|
let arrive = plan.done.iter().map(|(_, t, e, g, _)| t + e - g).fold(0.0, f64::max);
|
|
for (mut d, time, early, gap, site) in plan.done {
|
|
d.smart = plan.smart;
|
|
let wait = if plan.smart { arrive - (early - gap) - time } else { gap + self.rng.f() * 40.0 };
|
|
if site != 0 {
|
|
self.sites.job(d.id, site);
|
|
}
|
|
self.queue.push((now + wait.clamp(0.0, LONGEST_WAIT) as u64, d));
|
|
}
|
|
self.wave = Some(WaveStats {
|
|
kills: self.kills,
|
|
hits: self.hits,
|
|
launched: self.launched,
|
|
fired: self.sams.iter().map(|s| s.fired).sum(),
|
|
sams: self.sams.iter().filter(|s| s.alive()).count(),
|
|
objects: self.target_health.iter().sum(),
|
|
size: plan.total,
|
|
tally: self.tally,
|
|
});
|
|
let what = if plan.smart { "умный противник" } else { plan.approach.name() };
|
|
out.messages.push(format!("ВОЗДУШНАЯ ТРЕВОГА: налёт, {} целей ({what})", plan.total));
|
|
out.sound("minecraft:event.raid.horn", plan.centre, 64.0, 1.0);
|
|
}
|
|
|
|
/// Whether a raid is still being planned.
|
|
pub fn planning(&self) -> bool {
|
|
self.plan.is_some()
|
|
}
|
|
|
|
/// A drone ready to fly from `from`, not yet in the sky.
|
|
pub(super) fn make_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) -> Drone {
|
|
let id = self.id();
|
|
let spec = kind.spec();
|
|
// Its navigation error (CEP and a rare gross fault, #59/#65): the same for a manual launch and a raid.
|
|
// The raid's own ±4 spread of aim points is the tactical dispersal and stays on top of it.
|
|
let err = self.aim_error(kind);
|
|
let target = target + err;
|
|
let (vel, flight) = if spec.ballistic.is_some() {
|
|
let flat = horizontal(target - from);
|
|
let time = (flat.length() / spec.speed).max(30.0);
|
|
let up = (target.y - from.y + 0.5 * GRAVITY * time * time) / time;
|
|
(flat / time + DVec3::Y * up, Flight::Ballistic)
|
|
} else {
|
|
(dir.normalize_or(DVec3::NEG_Z) * spec.speed, Flight::Cruise)
|
|
};
|
|
Drone {
|
|
id,
|
|
kind,
|
|
pos: from,
|
|
vel,
|
|
target,
|
|
flight,
|
|
roll: 0.0,
|
|
spin: 0.0,
|
|
age: 0,
|
|
engaged: 0,
|
|
route: Vec::new(),
|
|
low: false,
|
|
jammed: false,
|
|
sector: 0,
|
|
smart: false,
|
|
nav: Nav::default(),
|
|
err,
|
|
hold: 0,
|
|
pace: Pace::default(),
|
|
}
|
|
}
|
|
|
|
/// The strikes a smart raid sends at the radars (they emit, so the enemy knows them whether or not they
|
|
/// fired): for the drones of `kinds` in order, the radar's place and id for those that go for one. Each radar
|
|
/// gets two or three, the fast strikers first, and all together no more than about a quarter of the raid.
|
|
pub(super) fn radar_strikes(&self, kinds: &[(DroneKind, u32)]) -> Vec<Option<(DVec3, u32)>> {
|
|
let mut radars: Vec<&Sam> = self.sams.iter().filter(|s| s.alive() && s.kind == SamKind::Radar).collect();
|
|
radars.sort_by_key(|s| s.id);
|
|
let flat: Vec<DroneKind> = kinds.iter().flat_map(|&(k, n)| std::iter::repeat_n(k, n as usize)).collect();
|
|
let mut jobs = vec![None; flat.len()];
|
|
if radars.is_empty() {
|
|
return jobs;
|
|
}
|
|
// Fast ones first (a stable sort keeps the raid's order among equals).
|
|
const RANK: [DroneKind; 8] =
|
|
[DroneKind::Kh22, DroneKind::Zircon, DroneKind::Iskander, DroneKind::Kinzhal, DroneKind::Kh101, DroneKind::Geran3, DroneKind::Lancet, DroneKind::Shahed];
|
|
let mut ranked: Vec<(usize, usize)> = flat.iter().enumerate().filter_map(|(i, k)| Some((RANK.iter().position(|r| r == k)?, i))).collect();
|
|
ranked.sort_by_key(|r| r.0);
|
|
let want = if flat.len() >= 24 { 3 } else { 2 };
|
|
let cap = (flat.len() / 4).max(1);
|
|
for (n, &(_, i)) in ranked.iter().take((radars.len() * want).min(cap)).enumerate() {
|
|
let radar = radars[n % radars.len()];
|
|
jobs[i] = Some((radar.pos, radar.id));
|
|
}
|
|
jobs
|
|
}
|
|
|
|
/// What a drone goes for: an object, or (by its kind and the raid's aim) a system. Returns the
|
|
/// point and the system's id.
|
|
fn choose_target(&mut self, kind: DroneKind, object: DVec3, aim: Aim) -> (DVec3, Option<u32>) {
|
|
let alive: Vec<&Sam> = self.sams.iter().filter(|s| s.alive()).collect();
|
|
if alive.is_empty() || kind.spec().decoy || aim == Aim::Objects {
|
|
return (object, None);
|
|
}
|
|
let pick = |list: &[&Sam], r: f64| -> Option<(DVec3, u32)> {
|
|
(!list.is_empty()).then(|| {
|
|
let s = list[(r * list.len() as f64) as usize % list.len()];
|
|
(s.pos, s.id)
|
|
})
|
|
};
|
|
let r = self.rng.f();
|
|
let roll = self.rng.f();
|
|
if aim == Aim::ObjectsAndSams {
|
|
return if roll < 0.33 { pick(&alive, r).map_or((object, None), |(p, id)| (p, Some(id))) } else { (object, None) };
|
|
}
|
|
// Smart: each kind at what it is good against; what gave itself away first (a jamming
|
|
// station, a radar, a system that fired).
|
|
// The planner has only what the enemy knows (#93): the radars (they emit) and the units that gave
|
|
// themselves away; the rest of the player's layout is not in its hands.
|
|
let known = self.known_units();
|
|
let alive: Vec<&Sam> = alive.iter().copied().filter(|s| Self::enemy_knows(s, &known)).collect();
|
|
let pick_known = |list: &[&Sam], r: f64| -> Option<(DVec3, u32)> {
|
|
let s = *list.get((r * list.len() as f64) as usize)?;
|
|
known.iter().find(|(id, _)| *id == s.id).map(|(_, pos)| (*pos, s.id))
|
|
};
|
|
let known_ew: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind == SamKind::Ew).collect();
|
|
if matches!(kind, DroneKind::Lancet | DroneKind::Kh22) && !known_ew.is_empty() && roll < 0.6 {
|
|
let s = known_ew[(r * known_ew.len() as f64) as usize % known_ew.len()];
|
|
return (known.iter().find(|(id, _)| *id == s.id).unwrap().1, Some(s.id));
|
|
}
|
|
// Short-range systems (guns, Tor, P1-SUN) for the Lancets; long-range ones and the radars
|
|
// that feed them for the fast missiles.
|
|
let guns: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind.armed() && s.kind.range() <= 300.0).collect();
|
|
let long: Vec<&Sam> =
|
|
alive.iter().copied().filter(|s| s.kind.range() >= 450.0 || s.kind == SamKind::Radar).collect();
|
|
let choice = match kind {
|
|
DroneKind::Lancet if roll < 0.75 => pick_known(&guns, r).or_else(|| pick_known(&alive, r)),
|
|
DroneKind::Kh22 | DroneKind::Zircon if roll < 0.45 => pick_known(&long, r),
|
|
DroneKind::Iskander | DroneKind::Kinzhal if roll < 0.3 => pick_known(&long, r),
|
|
DroneKind::Shahed | DroneKind::Geran3 if roll < 0.2 => pick_known(&alive, r),
|
|
_ => None,
|
|
};
|
|
choice.map_or((object, None), |(p, id)| (p, Some(id)))
|
|
}
|
|
|
|
/// The price of each cell of `grid` for this kind flying low: 1, and much more where a
|
|
/// system sees it there (its radar's reach for it, the land) and can shoot it.
|
|
pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
|
|
self.price_grid_on(grid, kind, None)
|
|
}
|
|
|
|
/// The same over known land: a cell's drone flies `height` over the land there, not over the system's feet,
|
|
/// so what a radar sees and what a hill hides follow the relief.
|
|
pub(super) fn price_grid_on(&self, grid: &Grid, kind: DroneKind, land: Option<&[f32]>) -> (Vec<f32>, usize) {
|
|
let mut cost = 0;
|
|
let spec = kind.spec();
|
|
let height = spec.clearance.max(6.0);
|
|
let known_now = self.known_units();
|
|
let n = grid.size;
|
|
let mut prices = vec![1.0f32; (n * n) as usize];
|
|
// Known jamming stations: drones that navigate by satellite keep away.
|
|
if kind.jammable() {
|
|
let known = self.known_units();
|
|
for s in self.sams.iter().filter(|s| s.kind == SamKind::Ew && s.alive() && known.iter().any(|k| k.0 == s.id)) {
|
|
let pos = known.iter().find(|(id, _)| *id == s.id).unwrap().1;
|
|
let (ci, cj) = grid.index(pos);
|
|
let r = (EW_RADIUS / grid.cell).ceil() as i32;
|
|
for i in (ci - r).max(0)..=(ci + r).min(n - 1) {
|
|
for j in (cj - r).max(0)..=(cj + r).min(n - 1) {
|
|
if horizontal(grid.centre((i, j), pos.y) - pos).length() < EW_RADIUS {
|
|
prices[(i * n + j) as usize] += 12.0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Only the systems the enemy knows of are priced in (#93): a hidden one is flown into.
|
|
for s in self.sams.iter().filter(|s| s.alive() && s.roles & spec.class.bit() != 0 && Self::enemy_knows(s, &known_now)) {
|
|
let pos = known_now.iter().find(|(id, _)| *id == s.id).unwrap().1;
|
|
let sp = s.kind.spec();
|
|
let (lo, hi) = sp.missile.as_ref().map_or((0.0, 0.0), |m| (m.min, m.max));
|
|
let gun = sp.gun.as_ref().map_or(0.0, |g| g.range);
|
|
// A gun needs its own radar; a missile also flies on the tracks of any radar within data-link range.
|
|
let own = sight::reach(s, kind, height);
|
|
let net = self.sams.iter().filter_map(|r| {
|
|
let at = known_now.iter().find(|(id, _)| *id == r.id)?.1;
|
|
(r.id != s.id && at.distance(pos) <= sight::LINK).then(|| sight::reach(r, kind, height))
|
|
}).fold(0.0, f64::max);
|
|
let (hi, gun) = (hi.min(own.max(net)), gun.min(own));
|
|
let far = hi.max(gun);
|
|
let at = pos + (sight::antenna(s) - s.pos);
|
|
let horizon = (pos == s.pos).then(|| self.horizons.get(&s.id)).flatten();
|
|
// Only the cells round it.
|
|
let (ci, cj) = grid.index(pos);
|
|
let r = (far / grid.cell).ceil() as i32 + 1;
|
|
for i in (ci - r).max(0)..=(ci + r).min(n - 1) {
|
|
for j in (cj - r).max(0)..=(cj + r).min(n - 1) {
|
|
cost += 1;
|
|
let ground = land.map(|l| l[(i * n + j) as usize]).filter(|g| g.is_finite());
|
|
let p = grid.centre((i, j), ground.map_or(pos.y + height, |g| f64::from(g) + height));
|
|
let d = horizontal(p - pos).length();
|
|
if d > far {
|
|
continue;
|
|
}
|
|
let missile = (lo..hi).contains(&d);
|
|
if !(missile || d < gun) {
|
|
continue;
|
|
}
|
|
if horizon.is_some_and(|h| h.hides(at, p)) {
|
|
continue;
|
|
}
|
|
prices[(i * n + j) as usize] += if missile { 30.0 } else { 18.0 };
|
|
}
|
|
}
|
|
}
|
|
(prices, cost)
|
|
}
|
|
|
|
/// Ticks an air start waits for the land under it to be told of.
|
|
const AIR_START_WAIT: u32 = 120;
|
|
|
|
/// A planned raid's drones take off when their time comes.
|
|
pub(super) fn release_queue(&mut self, ground: &mut Ground, out: &mut Out) {
|
|
let now = self.tick;
|
|
if self.queue.iter().all(|q| q.0 > now) {
|
|
return;
|
|
}
|
|
let (go, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queue).into_iter().partition(|q| q.0 <= now);
|
|
self.queue = wait;
|
|
let mut cancelled = 0;
|
|
for (t, mut d) in go {
|
|
// A site's job leaves its site one at a time, from its stock; a destroyed or empty site cancels it.
|
|
let from_site = self.sites.site_of(d.id);
|
|
// An air start (an off-map site's drone) was planned over land nobody had read. It waits (a second or
|
|
// two) for the land under it to be known, and begins above it if that is higher than the start: else it
|
|
// is born inside a hill and crashes at once.
|
|
let air = from_site.is_some_and(|id| self.sites.list.iter().any(|s| s.id == id && s.offmap));
|
|
if air {
|
|
let clear = if d.kind.spec().ballistic.is_some() { 10.0 } else { d.kind.spec().clearance };
|
|
match ground.at(d.pos, now) {
|
|
None if d.age < AIR_START_WAIT => {
|
|
// The game has no chunk there: it is asked to load the ones round the start (and kept a while).
|
|
let c = ((d.pos.x.floor() as i32) >> 4, (d.pos.z.floor() as i32) >> 4);
|
|
for (dx, dz) in [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)] {
|
|
ground.want_chunk((c.0 + dx, c.1 + dz));
|
|
}
|
|
d.age += 1;
|
|
self.queue.push((t, d));
|
|
continue;
|
|
}
|
|
Some(g) if d.pos.y < g + clear => d.pos.y = g + clear,
|
|
_ => {}
|
|
}
|
|
d.age = 0;
|
|
}
|
|
let slot = self.site_slot(d.id);
|
|
if let (Some(site), sites::Slot::Go | sites::Slot::Air) = (from_site, &slot) {
|
|
self.scout_launch(d.id, site, d.kind, d.pos);
|
|
}
|
|
match slot {
|
|
// Free: not a site's job. Air: an off-map site's drone, already in flight position.
|
|
sites::Slot::Free => {}
|
|
// The air start was planned over land nobody had read; if it is read by now and the land there is
|
|
// higher than the start, it begins above it (else it is born inside a hill and crashes at once).
|
|
sites::Slot::Air => {}
|
|
sites::Slot::Busy => {
|
|
self.queue.push((t, d));
|
|
continue;
|
|
}
|
|
sites::Slot::Gone => {
|
|
cancelled += 1;
|
|
continue;
|
|
}
|
|
sites::Slot::Go => {
|
|
// On a slope the pad's side or the muzzle's far end can stand in the land that rises there: the
|
|
// drone leaves from above the land at its own column, not from inside the hill.
|
|
if let Some(g) = ground.at(d.pos, now)
|
|
&& d.pos.y < g + 1.5
|
|
&& g < d.pos.y + 40.0
|
|
{
|
|
d.pos.y = g + 1.5;
|
|
}
|
|
let dir = d.vel.normalize_or_zero();
|
|
out.sound(d.kind.launch_sound(), d.pos, 1.0, 1.0);
|
|
out.particles("minecraft:flame", d.pos - dir * 1.5, 0.15, -dir * 0.1, 0.02, 4);
|
|
out.particles("minecraft:large_smoke", d.pos - dir * 2.5, 0.5, DVec3::new(0.0, 0.05, 0.0), 0.02, 4);
|
|
}
|
|
}
|
|
self.drones.push(d);
|
|
self.launched += 1;
|
|
}
|
|
if cancelled > 0 {
|
|
out.messages.push(format!("Пусковая площадка не запустила {cancelled}: нет запаса или уничтожена"));
|
|
}
|
|
}
|
|
|
|
/// Looks for the raid's launch sites. Returns whether they are settled (placed, or off the map: a side with
|
|
/// no suitable ground still launches, from the air at the ordered range; nothing is cancelled).
|
|
fn settle_sites(&mut self, plan: &mut Plan, ground: &mut Ground, out: &mut Out) -> bool {
|
|
plan.site_wait += 1;
|
|
let mut keep: Vec<DVec3> = self.sams.iter().filter(|s| s.alive()).map(|s| s.pos).collect();
|
|
keep.extend(self.targets.iter().copied());
|
|
keep.push(plan.centre);
|
|
let last = plan.site_wait >= sites::SEARCH_TICKS;
|
|
if !sites::search(&mut plan.wants, ground, plan.centre, &keep, self.tick, last) {
|
|
// The game loads the land of the places looked at; the player hears the enemy is preparing.
|
|
if !plan.said && plan.site_wait >= 40 {
|
|
plan.said = true;
|
|
out.messages.push("Противник готовит налёт: ищет площадки, местность подгружается".into());
|
|
}
|
|
return false;
|
|
}
|
|
use sites::Search;
|
|
// A side that found nothing is not given up (no raid is cancelled for it): its site is off the map.
|
|
// The pads, and the sites themselves; the old raid's sites go unless they still have jobs to fly.
|
|
let queued: Vec<u32> = self.queue.iter().map(|q| q.1.id).collect();
|
|
let refs = self.site_refs();
|
|
self.sites.retire(&queued, &refs);
|
|
for w in plan.wants.iter_mut() {
|
|
w.jobs = 0;
|
|
}
|
|
for it in plan.items.iter_mut() {
|
|
it.pad = plan.wants[it.want].jobs;
|
|
plan.wants[it.want].jobs += 1;
|
|
}
|
|
let mut shortened = false;
|
|
for w in plan.wants.iter_mut().filter(|w| w.jobs > 0) {
|
|
let offmap = w.state != Search::Found;
|
|
let mut pos = if offmap {
|
|
// At the ordered range over the raid's centre height: nothing stands there.
|
|
plan.centre + DVec3::new(w.bearing.sin(), 0.0, w.bearing.cos()) * w.ordered
|
|
} else {
|
|
w.found.unwrap_or_default()
|
|
};
|
|
if w.water {
|
|
pos.y += sites::DECK;
|
|
}
|
|
shortened |= !offmap && w.range < w.ordered - if w.class == sites::SiteClass::Battery { 1.0 } else { 75.0 };
|
|
w.site = self.sites.add(sites::LaunchSite {
|
|
id: 0,
|
|
side: w.side as u8,
|
|
sector: ew::sector(w.bearing) as u8,
|
|
bearing: w.bearing,
|
|
class: w.class,
|
|
pos,
|
|
range: if offmap { w.ordered } else { w.range },
|
|
water: w.water,
|
|
stock: w.jobs,
|
|
planned: w.jobs,
|
|
alive: true,
|
|
next_free: 0,
|
|
offmap,
|
|
});
|
|
}
|
|
if shortened {
|
|
out.messages.push("Пуск ближе обычного: дальше нет известной земли или воды".into());
|
|
}
|
|
// Each drone onto its pad, climbing off the ramp or leaving the tube (ballistic ones aim as always).
|
|
for it in plan.items.iter_mut() {
|
|
let w = &plan.wants[it.want];
|
|
let Some(site) = self.sites.get(w.site) else { continue };
|
|
// Off the map: it starts where the plan put it, cruising.
|
|
if site.offmap {
|
|
it.d.sector = site.sector;
|
|
continue;
|
|
}
|
|
let from = site.muzzle(it.pad);
|
|
let spec = it.d.kind.spec();
|
|
it.from = from;
|
|
it.d.pos = from;
|
|
if spec.ballistic.is_some() {
|
|
let flat = horizontal(it.d.target - from);
|
|
let time = (flat.length() / spec.speed).max(30.0);
|
|
it.d.vel = flat / time + DVec3::Y * ((it.d.target.y - from.y + 0.5 * GRAVITY * time * time) / time);
|
|
} else {
|
|
let pitch = site.class.pitch();
|
|
let dir = horizontal(it.d.target - from).normalize_or(site.inward());
|
|
it.d.vel = (dir * pitch.cos() + DVec3::Y * pitch.sin()) * spec.speed;
|
|
}
|
|
it.d.sector = site.sector;
|
|
}
|
|
let mine = |s: &&sites::LaunchSite| plan.wants.iter().any(|w| w.site == s.id);
|
|
let n = self.sites.list.iter().filter(mine).filter(|s| !s.offmap).count();
|
|
let off = self.sites.list.iter().filter(mine).filter(|s| s.offmap).count();
|
|
let barges = self.sites.list.iter().filter(mine).filter(|s| s.water).count();
|
|
if n > 0 {
|
|
out.messages.push(format!("Пусковые площадки противника: {n}{}", if barges > 0 { format!(" (на баржах: {barges})") } else { String::new() }));
|
|
}
|
|
if off > 0 {
|
|
out.messages.push(format!("Площадок за пределами загруженной карты: {off}: оттуда пуск без площадки, с высоты полёта"));
|
|
}
|
|
// Where they are is the enemy's secret, unless the sandbox's true picture is on.
|
|
if self.truth {
|
|
for site in self.sites.list.iter().filter(|s| plan.wants.iter().any(|w| w.site == s.id)) {
|
|
out.messages.push(format!(
|
|
"Площадка #{} ({:?}, сторона {}): {:.0} {:.0} {:.0}, запас {}",
|
|
site.id, site.class, site.side, site.pos.x, site.pos.y, site.pos.z, site.stock
|
|
));
|
|
}
|
|
}
|
|
plan.sited = true;
|
|
true
|
|
}
|
|
|
|
/// When the raid is over: what it cost both sides.
|
|
pub(super) fn report_wave(&mut self, out: &mut Out) {
|
|
let Some(w) = &self.wave else { return };
|
|
if self.plan.is_some() || !self.queue.is_empty() || self.drones.iter().any(|d| d.alive()) || !self.missiles.is_empty() || self.tick % 20 != 0 {
|
|
return;
|
|
}
|
|
let kills = self.kills - w.kills;
|
|
let through = self.hits - w.hits;
|
|
let launched = self.launched - w.launched;
|
|
// A system taken away during the raid takes its count along: never below none.
|
|
let fired: u32 = self.sams.iter().map(|s| s.fired).sum::<u32>().saturating_sub(w.fired);
|
|
let lost = w.sams.saturating_sub(self.sams.iter().filter(|s| s.alive()).count());
|
|
let objects = if self.target_health.is_empty() {
|
|
String::new()
|
|
} else {
|
|
let now: f64 = self.target_health.iter().sum();
|
|
format!(", объекты {:.0}% (потеряно {:.0}%)", now / self.target_health.len() as f64, (w.objects - now) / self.target_health.len() as f64)
|
|
};
|
|
out.messages.push(format!(
|
|
"Налёт окончен: сбито {kills} из {launched} (заявлено {}), прорвалось {through}; ракет ПВО {fired}; потеряно комплексов {lost}{objects}",
|
|
w.size
|
|
));
|
|
// Only what was counted; nothing is guessed for the rest.
|
|
let why = self.tally.since(w.tally).text();
|
|
if !why.is_empty() {
|
|
out.messages.push(format!("Неудачи ПВО: {why}"));
|
|
}
|
|
// What the counter-strikes did to the raid (#101): the launches that never happened.
|
|
let cut: u32 = self.strikes.cancelled.iter().map(|c| c.1).sum();
|
|
if cut > 0 {
|
|
let sites: Vec<String> = self.strikes.cancelled.iter().filter(|c| c.1 > 0).map(|c| format!("№{}", c.0)).collect();
|
|
out.messages.push(format!("Налёт сорван ударами по ПУ ({}): не взлетело {cut} из заявленных {}", sites.join(", "), w.size));
|
|
}
|
|
self.strikes.cancelled.clear();
|
|
self.wave = None;
|
|
}
|
|
}
|
|
|
|
fn route_length(from: DVec3, route: &[DVec3], to: DVec3) -> f64 {
|
|
let mut at = from;
|
|
let mut len = 0.0;
|
|
for p in route.iter().chain(std::iter::once(&to)) {
|
|
len += horizontal(*p - at).length();
|
|
at = *p;
|
|
}
|
|
len
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use super::sites::testland::{Land, serve};
|
|
|
|
#[test]
|
|
fn a_smart_raid_plans_and_flies_in_time() {
|
|
let mut sim = Sim::new(7);
|
|
for (i, kind) in SamKind::ALL.into_iter().enumerate() {
|
|
let id = sim.id();
|
|
sim.sams.push(Sam::new(id, kind, DVec3::new(-30.0 + 15.0 * i as f64, 70.0, 0.0), 0.0));
|
|
}
|
|
let mut ground = Ground::default();
|
|
let land = Land(|_, _| 64);
|
|
let mut out = Out::default();
|
|
for _ in 0..30 {
|
|
serve(&mut ground, &land, sim.tick);
|
|
sim.step(&mut ground, None, &mut out);
|
|
}
|
|
let t = std::time::Instant::now();
|
|
let order = RaidOrder {
|
|
kinds: vec![(DroneKind::Gerbera, 8), (DroneKind::Shahed, 12), (DroneKind::Kh101, 4), (DroneKind::Iskander, 2)],
|
|
approach: Approach::AllSides,
|
|
dist: 300.0,
|
|
aim: Aim::Smart,
|
|
};
|
|
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, -40.0)], &mut out);
|
|
let mut ticks = 0;
|
|
while sim.planning() {
|
|
serve(&mut ground, &land, sim.tick);
|
|
sim.step(&mut ground, None, &mut out);
|
|
ticks += 1;
|
|
}
|
|
eprintln!("planned in {:?} over {ticks} ticks, queued {}", t.elapsed(), sim.queue.len());
|
|
let t = std::time::Instant::now();
|
|
for k in 0..4000 {
|
|
let s = std::time::Instant::now();
|
|
out = Out::default();
|
|
serve(&mut ground, &land, sim.tick);
|
|
sim.step(&mut ground, None, &mut out);
|
|
assert!(s.elapsed().as_millis() < 200, "tick {k} took {:?}", s.elapsed());
|
|
}
|
|
eprintln!("4000 ticks in {:?}, kills {} hits {}", t.elapsed(), sim.kills, sim.hits);
|
|
assert!(sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()), "the raid never ended");
|
|
}
|
|
/// How many of the live and queued drones are aimed at each point (the drone's own aim: its target less its navigation error).
|
|
fn aimed_at(sim: &Sim, p: DVec3) -> Vec<DroneKind> {
|
|
sim.drones.iter().chain(sim.queue.iter().map(|q| &q.1)).filter(|d| d.alive() && (d.target - d.err).distance(p) < 6.0).map(|d| d.kind).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn a_smart_raid_goes_for_every_radar_and_the_enemys_adapting_does_not_turn_it_away() {
|
|
let mut sim = Sim::new(21);
|
|
let mut radars = Vec::new();
|
|
for (i, at) in [(-300.0, -200.0), (250.0, -250.0), (50.0, 300.0)].into_iter().enumerate() {
|
|
let id = sim.id();
|
|
let p = DVec3::new(at.0, 70.0, at.1);
|
|
sim.sams.push(Sam::new(id, SamKind::Radar, p, i as f64));
|
|
radars.push((id, p));
|
|
}
|
|
let ew = sim.id();
|
|
sim.sams.push(Sam::new(ew, SamKind::Ew, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
|
sim.targets.push(DVec3::new(0.0, 70.0, 0.0));
|
|
sim.target_health.push(100.0);
|
|
let mut ground = Ground::default();
|
|
let land = Land(|_, _| 64);
|
|
let mut out = Out::default();
|
|
for _ in 0..20 {
|
|
serve(&mut ground, &land, sim.tick);
|
|
sim.step(&mut ground, None, &mut out);
|
|
}
|
|
let mut order = RaidOrder::mix(36, 500.0);
|
|
order.aim = Aim::Smart;
|
|
order.approach = Approach::AllSides;
|
|
let total: u32 = order.kinds.iter().map(|k| k.1).sum();
|
|
sim.order_raid(&order, &sim.targets.clone(), &mut out);
|
|
while sim.planning() {
|
|
serve(&mut ground, &land, sim.tick);
|
|
sim.step(&mut ground, None, &mut out);
|
|
}
|
|
let check = |sim: &Sim, when: &str| {
|
|
let mut all = 0;
|
|
for (i, (_, p)) in radars.iter().enumerate() {
|
|
let kinds = aimed_at(sim, *p);
|
|
all += kinds.len();
|
|
assert!(kinds.len() >= 2, "{when}: radar {i} has only {kinds:?} aimed at it");
|
|
}
|
|
all
|
|
};
|
|
let planned = check(&sim, "planned");
|
|
assert!(planned as u32 <= total / 4 + 6, "{planned} of {total} at the radars (the EW station and the rest may add a few)");
|
|
// The first strikers are the fast ones.
|
|
assert!(radars.iter().any(|(_, p)| aimed_at(&sim, *p).iter().any(|k| matches!(k, DroneKind::Kh22 | DroneKind::Zircon | DroneKind::Iskander | DroneKind::Kinzhal))));
|
|
// The enemy has learnt of the jamming station and re-aims what it can: not these.
|
|
sim.intel.revealed.push((ew, DVec3::new(0.0, 70.0, 0.0), sim.tick));
|
|
for _ in 0..160 {
|
|
serve(&mut ground, &land, sim.tick);
|
|
sim.step(&mut ground, None, &mut out);
|
|
}
|
|
check(&sim, "after adapting");
|
|
}
|
|
|
|
#[test]
|
|
fn systems_ready_together_send_one_missile_at_a_drone() {
|
|
let mut sim = Sim::new(3);
|
|
// Vertical launchers of one kind side by side: the drone comes into their reach in the
|
|
// same tick, and they fire without turning.
|
|
for i in 0..6 {
|
|
let id = sim.id();
|
|
sim.sams.push(Sam::new(id, SamKind::Tor, DVec3::new(2.0 * i as f64, 70.0, 0.0), 0.0));
|
|
}
|
|
let mut ground = Ground::default();
|
|
let land = Land(|_, _| 64);
|
|
let mut out = Out::default();
|
|
for _ in 0..30 {
|
|
serve(&mut ground, &land, sim.tick);
|
|
sim.step(&mut ground, None, &mut out);
|
|
}
|
|
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, -400.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
|
|
let id = sim.drones[0].id;
|
|
for _ in 0..1200 {
|
|
sim.step(&mut ground, None, &mut out);
|
|
let on_it = sim.missiles.iter().filter(|m| m.target == id).count();
|
|
assert!(on_it <= 1, "{on_it} missiles on one Shahed");
|
|
}
|
|
assert!(sim.sams.iter().any(|s| s.fired > 0), "nothing was fired");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod big {
|
|
use super::*;
|
|
use super::sites::testland::{Land, serve};
|
|
|
|
#[test]
|
|
fn sixty_one_systems_and_a_big_smart_raid() {
|
|
let mut sim = Sim::new(11);
|
|
for i in 0..61 {
|
|
let kind = SamKind::ARMED[i % 5];
|
|
let id = sim.id();
|
|
let p = DVec3::new(((i % 8) as f64 - 4.0) * 40.0, 70.0, ((i / 8) as f64 - 4.0) * 40.0);
|
|
sim.sams.push(Sam::new(id, kind, p, 0.0));
|
|
}
|
|
sim.targets.push(DVec3::new(0.0, 70.0, 0.0));
|
|
sim.target_health.push(100.0);
|
|
let mut ground = Ground::default();
|
|
let land = Land(|_, _| 64);
|
|
let mut out = Out::default();
|
|
for _ in 0..200 {
|
|
serve(&mut ground, &land, sim.tick);
|
|
sim.step(&mut ground, None, &mut out);
|
|
}
|
|
let order = RaidOrder {
|
|
kinds: [DroneKind::Gerbera, DroneKind::Shahed, DroneKind::Geran3, DroneKind::Lancet,
|
|
DroneKind::Kh101, DroneKind::Kalibr, DroneKind::Kh22, DroneKind::Zircon,
|
|
DroneKind::Iskander, DroneKind::Kinzhal, DroneKind::Oreshnik]
|
|
.into_iter().zip([10, 12, 6, 6, 6, 6, 4, 4, 6, 4, 1]).collect(),
|
|
aim: Aim::Smart, approach: Approach::AllSides, dist: 800.0,
|
|
};
|
|
let t = std::time::Instant::now();
|
|
sim.order_raid(&order, &sim.targets.clone(), &mut out);
|
|
let mut ticks = 0;
|
|
let mut slowest = std::time::Duration::ZERO;
|
|
while sim.planning() {
|
|
let s = std::time::Instant::now();
|
|
serve(&mut ground, &land, sim.tick);
|
|
sim.step(&mut ground, None, &mut out);
|
|
slowest = slowest.max(s.elapsed());
|
|
ticks += 1;
|
|
}
|
|
eprintln!("planned {} in {:?} over {ticks} ticks, slowest {:?}", sim.queue.len(), t.elapsed(), slowest);
|
|
let mut worst = std::time::Duration::ZERO;
|
|
for _ in 0..3000 {
|
|
let s = std::time::Instant::now();
|
|
out = Out::default();
|
|
serve(&mut ground, &land, sim.tick);
|
|
sim.step(&mut ground, None, &mut out);
|
|
worst = worst.max(s.elapsed());
|
|
}
|
|
eprintln!("worst tick {:?}", worst);
|
|
}
|
|
}
|