9bc94fa8aa
build / mod (push) Successful in 3m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
929 lines
40 KiB
Rust
929 lines
40 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,
|
|
}
|
|
|
|
/// 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,
|
|
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.
|
|
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))
|
|
}
|
|
}
|
|
|
|
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();
|
|
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 (target, at_sam) = 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,
|
|
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;
|
|
if plan.unanswered.is_empty() || plan.waited >= 40 {
|
|
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, 2400.0) 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,
|
|
}
|
|
}
|
|
|
|
/// 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).
|
|
let known = self.known_units();
|
|
let known_ew: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind == SamKind::Ew && known.iter().any(|k| k.0 == s.id)).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 (s.pos, 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() <= 200.0).collect();
|
|
let long: Vec<&Sam> =
|
|
alive.iter().copied().filter(|s| s.kind.range() >= 300.0 || s.kind == SamKind::Radar).collect();
|
|
let choice = match kind {
|
|
DroneKind::Lancet if roll < 0.75 => pick(&guns, r).or_else(|| pick(&alive, r)),
|
|
DroneKind::Kh22 | DroneKind::Zircon if roll < 0.45 => pick(&long, r),
|
|
DroneKind::Iskander | DroneKind::Kinzhal if roll < 0.3 => pick(&long, r),
|
|
DroneKind::Shahed | DroneKind::Geran3 if roll < 0.2 => pick(&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 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 (ci, cj) = grid.index(s.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), s.pos.y) - s.pos).length() < EW_RADIUS {
|
|
prices[(i * n + j) as usize] += 12.0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for s in self.sams.iter().filter(|s| s.alive() && s.roles & spec.class.bit() != 0) {
|
|
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(|r| r.alive() && r.id != s.id && r.pos.distance(s.pos) <= sight::LINK)
|
|
.map(|r| 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 = sight::antenna(s);
|
|
let horizon = self.horizons.get(&s.id);
|
|
// Only the cells round it.
|
|
let (ci, cj) = grid.index(s.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(s.pos.y + height, |g| f64::from(g) + height));
|
|
let d = horizontal(p - s.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)
|
|
}
|
|
|
|
/// A planned raid's drones take off when their time comes.
|
|
pub(super) fn release_queue(&mut self, 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, d) in go {
|
|
// A site's job leaves its site one at a time, from its stock; a destroyed or empty site cancels it.
|
|
match self.site_slot(d.id) {
|
|
sites::Slot::Free => {}
|
|
sites::Slot::Busy => {
|
|
self.queue.push((t, d));
|
|
continue;
|
|
}
|
|
sites::Slot::Gone => {
|
|
cancelled += 1;
|
|
continue;
|
|
}
|
|
sites::Slot::Go => {
|
|
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 given up: drones whose
|
|
/// side has no suitable ground go to another side's site, or are cancelled with a message; a raid left with
|
|
/// nothing is cancelled whole).
|
|
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) {
|
|
return false;
|
|
}
|
|
use sites::Search;
|
|
// A side that found nothing hands its drones to a site of the same class and range on another side.
|
|
let mut moved = 0;
|
|
let mut dropped = 0;
|
|
let found: Vec<usize> = (0..plan.wants.len()).filter(|&i| plan.wants[i].state == Search::Found).collect();
|
|
let mut keep_items = Vec::new();
|
|
for mut it in std::mem::take(&mut plan.items) {
|
|
let w = &plan.wants[it.want];
|
|
if w.state != Search::Found {
|
|
let (class, range) = (w.class, w.ordered);
|
|
let near = |x: &sites::Want| x.class == class && (x.ordered - range).abs() < range * 0.35 + 30.0;
|
|
match found.iter().copied().find(|&i| near(&plan.wants[i])) {
|
|
Some(i) => {
|
|
it.want = i;
|
|
moved += 1;
|
|
}
|
|
None => {
|
|
dropped += 1;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
keep_items.push(it);
|
|
}
|
|
plan.items = keep_items;
|
|
if moved + dropped > 0 {
|
|
out.messages.push(format!(
|
|
"Нет пригодной земли для части площадок: {moved} целей ушли с другой стороны, {dropped} отменено"
|
|
));
|
|
}
|
|
plan.total -= dropped;
|
|
if plan.items.is_empty() {
|
|
out.messages.push("Налёт отменён: нет пригодной известной земли для пусковых площадок".into());
|
|
return false;
|
|
}
|
|
// 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();
|
|
self.sites.retire(&queued);
|
|
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.state == Search::Found && w.jobs > 0) {
|
|
let mut pos = w.found.unwrap_or_default();
|
|
if w.water {
|
|
pos.y += sites::DECK;
|
|
}
|
|
shortened |= 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: w.range,
|
|
water: w.water,
|
|
stock: w.jobs,
|
|
planned: w.jobs,
|
|
alive: true,
|
|
next_free: 0,
|
|
});
|
|
}
|
|
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 };
|
|
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 n = self.sites.list.iter().filter(|s| plan.wants.iter().any(|w| w.site == s.id)).count();
|
|
let barges = self.sites.list.iter().filter(|s| s.water && plan.wants.iter().any(|w| w.site == s.id)).count();
|
|
out.messages.push(format!("Пусковые площадки противника: {n}{}", if barges > 0 { format!(" (на баржах: {barges})") } else { String::new() }));
|
|
// 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}"));
|
|
}
|
|
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");
|
|
}
|
|
#[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 mut w = crate::wave::WaveConfig::default();
|
|
w.counts = [10, 12, 6, 6, 6, 6, 4, 4, 6, 4, 1];
|
|
w.aim = Aim::Smart;
|
|
w.approach = Approach::AllSides;
|
|
w.dist = 800.0;
|
|
let t = std::time::Instant::now();
|
|
sim.order_raid(&w.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);
|
|
}
|
|
}
|