Files
airdef/airdef-mod/src/sim/raid.rs
T
2026-11-02 23:56:52 +03:00

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(&centre) = 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);
}
}