//! 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>, routes: HashMap<(DroneKind, i32, i32, i32), Vec>, items: Vec, 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, 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, unanswered: Vec, 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, 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, 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 = 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 { 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 = 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> { 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 = 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) { 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, 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, 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 = 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 = 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::().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 = 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 { 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); } }