Raid planner: plan over several ticks at full detail instead of in one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 23:37:14 +03:00
parent bf966a4dbe
commit f0fbc397d8
3 changed files with 186 additions and 54 deletions
+3
View File
@@ -55,6 +55,9 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
));
}
}
if sim.planning() {
lines.push(line("Противник планирует налёт…".into(), YELLOW));
}
if !sim.queue.is_empty() {
lines.push(line(format!("Ещё взлетает: {}", sim.queue.len()), PINK));
}
+7 -1
View File
@@ -618,7 +618,7 @@ impl DroneKind {
/// Height over the target where the Oreshnik's blocks release their submunitions.
const BLOCK_SPLIT: f64 = 300.0;
#[derive(PartialEq)]
#[derive(PartialEq, Clone, Copy)]
enum Flight {
Cruise,
Dive,
@@ -628,6 +628,7 @@ enum Flight {
Falling,
}
#[derive(Clone)]
pub struct Drone {
pub id: u32,
pub kind: DroneKind,
@@ -1118,6 +1119,8 @@ pub struct Sim {
later: Vec<(u64, DVec3, u32, bool)>,
/// A planned raid's drones waiting for their launch tick.
pub queue: Vec<(u64, Drone)>,
/// A raid being planned.
plan: Option<raid::Plan>,
/// Each marked object's health, 0 to 100 (`targets`' order).
pub target_health: Vec<f64>,
/// The raid in progress, for its report.
@@ -1153,6 +1156,7 @@ impl Sim {
horizons: HashMap::new(),
later: Vec::new(),
queue: Vec::new(),
plan: None,
target_health: Vec::new(),
wave: None,
rec: None,
@@ -1241,12 +1245,14 @@ impl Sim {
self.fx.clear();
self.blasts.clear();
self.queue.clear();
self.plan = None;
self.wave = None;
}
/// One game tick.
pub fn step(&mut self, ground: &mut Ground, player: Option<DVec3>, out: &mut Out) {
self.tick += 1;
self.plan_step(out);
self.release_queue();
self.step_drones(ground, player, out);
self.step_later(ground, player, out);
+176 -53
View File
@@ -60,6 +60,32 @@ pub struct RaidOrder {
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,
}
/// 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)
done: Vec<(Drone, f64, f64, f64)>,
smart: bool,
approach: Approach,
total: u32,
centre: DVec3,
}
/// The raid in progress: the counts at its start, for the report.
pub(super) struct WaveStats {
kills: u32,
@@ -73,7 +99,9 @@ pub(super) struct WaveStats {
/// The planner's grid: cells this big, at most this many a side.
const CELL: f64 = 16.0;
const CELLS: i32 = 120;
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.
const WORK_PER_TICK: usize = 40_000;
/// The planner's grid over a raid: its corner, its cells' size and how many a side.
struct Grid {
@@ -93,7 +121,8 @@ impl Grid {
}
/// The cheapest way over `grid` from `from` to `to` by its prices (A*): points along it, `to` last.
fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> Vec<DVec3> {
fn plan_route(grid: &Grid, prices: &[f32], 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);
@@ -108,6 +137,7 @@ fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> Vec<DVec3>
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 {
@@ -130,7 +160,7 @@ fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> Vec<DVec3>
}
}
if came[at(goal)] == u32::MAX {
return vec![to];
return (vec![to], opened);
}
let mut cells = vec![goal];
let mut c = at(goal);
@@ -142,7 +172,7 @@ fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> Vec<DVec3>
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
(route, opened)
}
/// A cell in the A* queue: the cheapest first.
@@ -161,10 +191,10 @@ impl PartialOrd for Open {
}
impl Sim {
/// Launches a raid on `objects` (at least one point).
/// 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 };
let now = self.tick;
let total: u32 = order.kinds.iter().map(|k| k.1).sum();
if total == 0 {
return 0;
@@ -172,15 +202,12 @@ impl Sim {
let bearing = self.rng.f() * TAU;
let second = bearing + std::f64::consts::PI * (0.6 + self.rng.f() * 0.6);
let smart = order.aim == Aim::Smart;
// (drone, its flight time to the target in ticks, how early it should arrive, its wave's delay)
let mut planned: Vec<(Drone, f64, f64, f64)> = 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 about the same bearing to the same target.
// 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 prices: HashMap<DroneKind, Vec<f32>> = HashMap::new();
let mut routes: HashMap<(DroneKind, i32, i32, i32), Vec<DVec3>> = HashMap::new();
let mut items = Vec::new();
for (group, &(kind, count)) in order.kinds.iter().enumerate() {
let spec = kind.spec();
for _ in 0..count {
@@ -195,44 +222,82 @@ impl Sim {
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 };
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 mut path = from.distance(aim);
if smart && spec.ballistic.is_none() && !spec.decoy && !at_sam.is_some_and(|_| kind == DroneKind::Lancet) {
let key = (kind, (a.rem_euclid(TAU) / 0.35) as i32, (target.x / 24.0) as i32, (target.z / 24.0) as i32);
let route = match routes.get(&key) {
Some(r) => r.clone(),
None => {
if !prices.contains_key(&kind) {
let p = self.price_grid(&grid, kind);
prices.insert(kind, p);
}
let r = plan_route(&grid, &prices[&kind], from, aim);
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 => {
let flat = horizontal(aim - from).length();
(flat / spec.speed).max(30.0)
}
_ => path / spec.speed,
};
let d = self.make_drone(kind, from, horizontal(aim - from), aim);
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 wave_gap = if order.approach == Approach::Waves { group as f64 * 300.0 } else { 0.0 };
planned.push((d, time, early, wave_gap));
let gap = if order.approach == Approach::Waves { group as f64 * 300.0 } else { 0.0 };
items.push(Item { d, from, aim, key, early, gap });
}
}
self.plan = Some(Plan {
grid,
prices: HashMap::new(),
routes: HashMap::new(),
items,
next: 0,
done: Vec::new(),
smart,
approach: order.approach,
total,
centre,
});
// A raid without routes is ready at once.
self.plan_step(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, out: &mut Out) {
let Some(mut plan) = self.plan.take() else { 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();
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 (p, cost) = self.price_grid(&plan.grid, kind);
work += cost;
plan.prices.insert(kind, p);
}
let (r, cost) = plan_route(&plan.grid, &plan.prices[&kind], 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.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 arrive = planned.iter().map(|(_, t, e, g)| t + e - g).fold(0.0, f64::max);
for (d, time, early, gap) in planned {
let wait = if smart { arrive - (early - gap) - time } else { gap + self.rng.f() * 40.0 };
let now = self.tick;
let arrive = plan.done.iter().map(|(_, t, e, g)| t + e - g).fold(0.0, f64::max);
for (d, time, early, gap) in plan.done {
let wait = if plan.smart { arrive - (early - gap) - time } else { gap + self.rng.f() * 40.0 };
self.queue.push((now + wait.clamp(0.0, 2400.0) as u64, d));
}
self.wave = Some(WaveStats {
@@ -242,12 +307,16 @@ impl Sim {
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: total,
size: plan.total,
});
let what = if smart { "умный противник" } else { order.approach.name() };
out.messages.push(format!("ВОЗДУШНАЯ ТРЕВОГА: налёт, {total} целей ({what})"));
out.sound("minecraft:event.raid.horn", centre, 64.0, 1.0);
total
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.
@@ -311,7 +380,8 @@ impl Sim {
/// 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.
fn price_grid(&self, grid: &Grid, kind: DroneKind) -> Vec<f32> {
fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
let mut cost = 0;
let spec = kind.spec();
let height = spec.clearance.max(6.0);
let n = grid.size;
@@ -329,6 +399,7 @@ impl Sim {
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 p = grid.centre((i, j), s.pos.y + height);
let d = horizontal(p - s.pos).length();
if d > far {
@@ -345,7 +416,7 @@ impl Sim {
}
}
}
prices
(prices, cost)
}
/// A planned raid's drones take off when their time comes.
@@ -365,7 +436,7 @@ impl Sim {
/// 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.queue.is_empty() || self.drones.iter().any(|d| d.alive()) || !self.missiles.is_empty() || self.tick % 20 != 0 {
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;
@@ -421,7 +492,12 @@ mod tests {
aim: Aim::Smart,
};
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, -40.0)], &mut out);
eprintln!("planned in {:?}, queued {}", t.elapsed(), sim.queue.len());
let mut ticks = 0;
while sim.planning() {
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();
@@ -433,3 +509,50 @@ mod tests {
assert!(sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()), "the raid never ended");
}
}
#[cfg(test)]
mod big {
use super::*;
#[test]
fn sixty_one_systems_and_a_big_smart_raid() {
let mut sim = Sim::new(11);
for i in 0..61 {
let kind = SamKind::ALL[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 mut out = Out::default();
for _ in 0..200 {
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();
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();
sim.step(&mut ground, None, &mut out);
worst = worst.max(s.elapsed());
}
eprintln!("worst tick {:?}", worst);
}
}