Files
airdef/airdef-mod/src/sim/logistics.rs
T
2026-11-03 00:18:44 +03:00

378 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Logistics: once a depot stands, magazines are no longer refilled from
//! nowhere. A system out of missiles waits; one within reach of a depot
//! reloads from its stock; the reload trucks fill up at a depot and drive
//! reloads to the systems that wait, nearest first, and back.
use super::*;
/// How near a depot a system reloads straight from it.
const DEPOT_REACH: f64 = 60.0;
/// How near a truck must come to hand a reload over, or to load up at a depot.
const HANDOVER: f64 = 10.0;
/// What a truck carries.
const TRUCK_LOAD: u32 = 2;
const STOCK_LIMIT: u32 = 1_000_000;
impl Sim {
/// Whether ammunition is counted: a depot was set up (without one, magazines refill as
/// before). A destroyed depot still counts: losing the last one leaves the systems short,
/// not supplied from nowhere again.
pub fn logistics(&self) -> bool {
self.sams.iter().any(|s| s.kind == SamKind::Depot)
}
/// Prepare one explicitly typed round at a nearby living depot. Any displaced air round is retained there.
pub fn load_ground_round(&mut self, i: usize) -> Result<String, String> {
let s = self.sams.get(i).ok_or("Нет такого комплекса")?;
if s.kind != SamKind::S400 { return Err("Для этого комплекса нет игрового профиля RM48U".into()); }
if !s.alive() || s.moving() || s.preparing.is_some() || s.reloading > 0 || s.part(Part::Launcher) <= 0.0 {
return Err("Комплекс должен быть исправен и развёрнут".into());
}
if s.ground_return { return Err("Сначала отмените заявку на вывоз RM48U".into()); }
if s.ground_rounds >= s.cells() { return Err("Нет свободной ячейки для RM48U".into()); }
let depot = self.sams.iter().enumerate().filter(|(_, d)| d.kind == SamKind::Depot && d.alive() && d.ground_stock > 0 && d.pos.distance(s.pos) < DEPOT_REACH)
.min_by(|(_, a), (_, b)| a.pos.distance(s.pos).total_cmp(&b.pos.distance(s.pos)).then(a.id.cmp(&b.id)))
.map(|(j, _)| j);
let Some(depot) = depot else {
if s.ground_request { return Err("Заявка RM48U уже ожидает ТЗМ".into()); }
if !self.sams.iter().any(|d| d.alive() && ((d.kind == SamKind::Depot && d.ground_stock > 0) || (d.kind == SamKind::Tzm && d.ground_stock > 0))) {
return Err("Нет живого склада с RM48U рядом (60 блоков)".into());
}
if !self.sams.iter().any(|t| t.kind == SamKind::Tzm && t.alive()) { return Err("Для доставки RM48U нужна исправная ТЗМ".into()); }
self.sams[i].ground_request = true;
return Ok(format!("С-400 №{}: заявка RM48U ожидает доставки ТЗМ", i + 1));
};
let displaced = u32::from(self.sams[i].missiles + self.sams[i].ground_rounds >= self.sams[i].cells());
let air_48n6_stock = self.sams[depot].air_48n6_stock.checked_add(displaced).ok_or("Запас склада переполнен")?;
self.sams[i].missiles -= displaced;
self.sams[i].ground_rounds += 1;
self.sams[i].ground_request = false;
if self.sams[i].ground_rounds >= self.sams[i].cells() && self.sams[i].kind.spec().gun.is_none() { self.sams[i].empty = false; }
self.sams[depot].ground_stock -= 1;
self.sams[depot].air_48n6_stock = air_48n6_stock;
Ok(format!("С-400 №{}: загружена RM48U (игровой профиль), ударных {}, ПВО {}; на складе RM48U {}, снятых ракет ПВО {}", i + 1, self.sams[i].ground_rounds, self.sams[i].missiles, self.sams[depot].ground_stock, self.sams[depot].air_48n6_stock))
}
/// Return a loaded ground round to a nearby depot; restore a previously removed air round if available.
pub fn unload_ground_round(&mut self, i: usize) -> Result<String, String> {
let s = self.sams.get(i).ok_or("Нет такого комплекса")?;
if s.kind != SamKind::S400 || !s.alive() || s.moving() || s.preparing.is_some() || s.reloading > 0 || s.part(Part::Launcher) <= 0.0 || s.ground_rounds == 0 {
return Err("Нужен исправный развёрнутый С-400 с загруженной RM48U".into());
}
if s.ground_request { return Err("Сначала отмените заявку на доставку RM48U".into()); }
if s.ground_return { return Err("Заявка на вывоз RM48U уже ожидает ТЗМ".into()); }
let depot = self.sams.iter().enumerate().filter(|(_, d)| d.kind == SamKind::Depot && d.alive() && d.pos.distance(s.pos) < DEPOT_REACH)
.min_by(|(_, a), (_, b)| a.pos.distance(s.pos).total_cmp(&b.pos.distance(s.pos)).then(a.id.cmp(&b.id)))
.map(|(j, _)| j);
let Some(depot) = depot else {
if !self.sams.iter().any(|d| d.kind == SamKind::Depot && d.alive() && d.ground_stock < STOCK_LIMIT) { return Err("Нет доступного склада для возврата RM48U".into()); }
if !self.sams.iter().any(|t| t.kind == SamKind::Tzm && t.alive() && t.drive_speed() > 0.0 && t.ground_stock.saturating_add(t.air_48n6_stock) < TRUCK_LOAD) { return Err("Нет исправной ТЗМ со свободным местом для RM48U".into()); }
self.sams[i].ground_return = true;
return Ok(format!("С-400 №{}: RM48U зарезервирована, ожидается вывоз ТЗМ", i + 1));
};
let stock = self.sams[depot].ground_stock.checked_add(1).filter(|n| *n <= STOCK_LIMIT).ok_or("Запас склада переполнен")?;
self.sams[i].ground_rounds -= 1;
self.sams[depot].ground_stock = stock;
if self.sams[depot].air_48n6_stock > 0 {
self.sams[depot].air_48n6_stock -= 1;
self.sams[i].missiles += 1;
}
Ok(format!("С-400 №{}: выгружена RM48U; ударных {}, ПВО {}", i + 1, self.sams[i].ground_rounds, self.sams[i].missiles))
}
pub fn cancel_ground_request(&mut self, i: usize) -> Result<String, String> {
let s = self.sams.get_mut(i).ok_or("Нет такого комплекса")?;
if s.kind != SamKind::S400 || (!s.ground_request && !s.ground_return) { return Err("Нет ожидающей заявки RM48U".into()); }
s.ground_request = false;
s.ground_return = false;
Ok(format!("С-400 №{}: заявка RM48U отменена", i + 1))
}
/// Reloads from depots near the systems, and the trucks' rounds.
pub(super) fn step_supply(&mut self, out: &mut Out) {
if !self.logistics() {
for s in &mut self.sams {
s.empty = false;
}
return;
}
let now = self.tick;
// Systems by a depot reload from it.
for i in 0..self.sams.len() {
if !self.sams[i].empty || !self.sams[i].alive() {
continue;
}
let pos = self.sams[i].pos;
if let Some(d) = self
.sams
.iter()
.position(|d| d.kind == SamKind::Depot && d.alive() && d.cargo > 0 && d.pos.distance(pos) < DEPOT_REACH)
{
self.sams[d].cargo -= 1;
self.reload_now(i, out);
}
}
// A destroyed truck drops its job, for another to take.
for s in self.sams.iter_mut().filter(|s| s.kind == SamKind::Tzm && !s.alive()) {
s.job = None;
}
// The trucks: a job each, when idle.
for t in 0..self.sams.len() {
let truck = &self.sams[t];
if truck.kind != SamKind::Tzm || !truck.alive() || truck.moving() || truck.drive_speed() <= 0.0 || now % 10 != t as u64 % 10 {
continue;
}
let at = truck.pos;
let truck_id = truck.id;
// A return is picked up physically; its cargo remains marked until a depot accepts it.
let can_pick = truck.ground_stock == 0 && truck.air_48n6_stock < TRUCK_LOAD;
let return_work = truck.return_cargo || (can_pick && self.sams.iter().any(|s| s.kind == SamKind::S400 && s.alive() && s.ground_return));
if return_work {
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive() && s.id != truck_id).filter_map(|s| s.job).collect();
let target = if truck.return_cargo {
self.sams.iter().filter(|s| s.kind == SamKind::Depot && s.alive() && s.ground_stock < STOCK_LIMIT && !self.routes.out_of_reach(truck_id, s.id, now)).min_by(|a,b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
} else {
self.sams.iter().filter(|s| s.kind == SamKind::S400 && s.alive() && s.ground_return && s.ground_rounds > 0 && !s.moving() && s.preparing.is_none() && s.reloading == 0 && s.part(Part::Launcher) > 0.0 && !taken.contains(&s.id) && !self.routes.out_of_reach(truck_id, s.id, now)).min_by(|a,b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
}.map(|s| (s.id, s.pos));
self.sams[t].job = target.map(|x| x.0);
if let Some((id, to)) = target {
if at.distance(to) > HANDOVER {
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
self.order_move_near(t, to + side, 3.5);
} else if let Some(j) = self.sams.iter().position(|s| s.id == id) {
if self.sams[t].return_cargo {
self.sams[j].ground_stock += 1;
self.sams[t].ground_stock -= 1;
self.sams[t].return_cargo = false;
} else {
self.sams[j].ground_rounds -= 1;
self.sams[j].ground_return = false;
self.sams[t].ground_stock += 1;
self.sams[t].return_cargo = true;
if self.sams[t].air_48n6_stock > 0 && self.sams[j].missiles + self.sams[j].ground_rounds < self.sams[j].cells() {
self.sams[t].air_48n6_stock -= 1;
self.sams[j].missiles += 1;
}
}
self.sams[t].job = None;
out.sound("minecraft:block.piston.extend", at, 1.5, 0.8);
}
}
continue;
}
// Typed strike cargo takes precedence over generic reloads. Jobs are reconstructed
// after loading a save; the request and physical cargo survive independently.
let typed_work = truck.ground_stock > 0 || truck.air_48n6_stock > 0 ||
(self.sams.iter().any(|s| s.kind == SamKind::S400 && s.alive() && s.ground_request)
&& self.sams.iter().any(|s| s.kind == SamKind::Depot && s.alive() && s.ground_stock > 0));
if typed_work {
if let Some(id) = self.sams[t].job {
let valid = self.sams.iter().find(|s| s.id == id).is_some_and(|s| s.alive() && !self.routes.out_of_reach(truck_id, s.id, now) && (s.kind == SamKind::Depot || (self.sams[t].ground_stock > 0 && s.kind == SamKind::S400 && s.ground_request)));
if !valid { self.sams[t].job = None; }
}
if let Some(id) = self.sams[t].job {
if let Some(j) = self.sams.iter().position(|s| s.id == id && s.alive()) {
if self.sams[j].pos.distance(at) <= HANDOVER {
if self.sams[j].kind == SamKind::Depot {
if let Some(n) = self.sams[j].air_48n6_stock.checked_add(self.sams[t].air_48n6_stock) {
self.sams[j].air_48n6_stock = n;
self.sams[t].air_48n6_stock = 0;
}
if self.sams[t].ground_stock > 0 && !self.sams.iter().any(|s| s.kind == SamKind::S400 && s.alive() && s.ground_request) && self.sams[j].ground_stock < STOCK_LIMIT {
let take = self.sams[t].ground_stock.min(STOCK_LIMIT - self.sams[j].ground_stock);
self.sams[j].ground_stock += take;
self.sams[t].ground_stock -= take;
}
if self.sams[t].ground_stock == 0 && self.sams[j].ground_stock > 0 && self.sams[t].air_48n6_stock == 0 && self.sams.iter().any(|s| s.kind == SamKind::S400 && s.alive() && s.ground_request) {
self.sams[j].ground_stock -= 1;
self.sams[t].ground_stock += 1;
}
} else if self.sams[j].kind == SamKind::S400 && self.sams[j].ground_request && !self.sams[j].moving() && self.sams[j].preparing.is_none() && self.sams[j].reloading == 0 && self.sams[j].part(Part::Launcher) > 0.0 && self.sams[j].ground_rounds < self.sams[j].cells() && self.sams[t].ground_stock > 0 {
let displaced = u32::from(self.sams[j].missiles + self.sams[j].ground_rounds >= self.sams[j].cells());
self.sams[j].missiles -= displaced;
self.sams[j].ground_rounds += 1;
if self.sams[j].ground_rounds >= self.sams[j].cells() && self.sams[j].kind.spec().gun.is_none() { self.sams[j].empty = false; }
self.sams[j].ground_request = false;
self.sams[t].ground_stock -= 1;
self.sams[t].air_48n6_stock += displaced;
out.sound("minecraft:block.piston.extend", at, 1.5, 0.8);
}
self.sams[t].job = None;
} else {
let to = self.sams[j].pos;
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
self.order_move_near(t, to + side, 3.5);
continue;
}
} else { self.sams[t].job = None; }
}
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive() && s.id != truck_id).filter_map(|s| s.job).collect();
let next = if self.sams[t].ground_stock > 0 && self.sams.iter().any(|s| s.kind == SamKind::S400 && s.alive() && s.ground_request) {
self.sams.iter().filter(|s| s.kind == SamKind::S400 && s.alive() && s.ground_request && !s.moving() && s.preparing.is_none() && s.reloading == 0 && s.part(Part::Launcher) > 0.0 && s.ground_rounds < s.cells() && !taken.contains(&s.id) && !self.routes.out_of_reach(truck_id, s.id, now)).min_by(|a,b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
} else {
self.sams.iter().filter(|s| s.kind == SamKind::Depot && s.alive() && (self.sams[t].air_48n6_stock > 0 || (self.sams[t].ground_stock > 0 && s.ground_stock < STOCK_LIMIT) || (self.sams[t].ground_stock == 0 && s.ground_stock > 0)) && !self.routes.out_of_reach(truck_id, s.id, now)).min_by(|a,b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
}.map(|s| (s.id, s.pos));
if let Some((id, to)) = next {
self.sams[t].job = Some(id);
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
if to.distance(at) > HANDOVER { self.order_move_near(t, to + side, 3.5); }
}
continue;
}
// Where it was going: hand over, or load up.
if let Some(job) = truck.job
&& let Some(j) = self.sams.iter().position(|s| s.id == job)
{
if self.sams[j].pos.distance(at) <= HANDOVER {
if self.sams[j].kind == SamKind::Depot {
let take = TRUCK_LOAD.saturating_sub(self.sams[t].cargo).min(self.sams[j].cargo);
self.sams[j].cargo -= take;
self.sams[t].cargo += take;
out.sound("minecraft:block.piston.extend", at, 1.5, 0.8);
} else if self.sams[j].empty && self.sams[t].cargo > 0 {
self.sams[t].cargo -= 1;
self.reload_now(j, out);
}
self.sams[t].job = None;
} else if self.sams[j].alive() && (self.sams[j].empty || self.sams[j].kind == SamKind::Depot) {
// Stopped short of it (it drove off meanwhile): on to where it stands now.
let to = self.sams[j].pos;
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
self.order_move_near(t, to + side, 3.5);
continue;
} else {
self.sams[t].job = None;
}
}
// A new job: the nearest waiting system no other truck is driving to, or a depot to load at.
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive()).filter_map(|s| s.job).collect();
let next = if self.sams[t].cargo > 0 {
self.sams
.iter()
.filter(|s| s.empty && s.alive() && !s.moving() && !taken.contains(&s.id) && !self.routes.out_of_reach(truck_id, s.id, now))
.min_by(|a, b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
.map(|s| (s.id, s.pos))
} else {
None
};
let next = next.or_else(|| {
(self.sams[t].cargo < TRUCK_LOAD)
.then(|| {
self.sams
.iter()
.filter(|d| d.kind == SamKind::Depot && d.alive() && d.cargo > 0 && !self.routes.out_of_reach(truck_id, d.id, now))
.min_by(|a, b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
.map(|d| (d.id, d.pos))
})
.flatten()
});
if let Some((id, to)) = next {
self.sams[t].job = Some(id);
// Stop beside it, not on it.
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
if to.distance(at) > HANDOVER {
self.order_move_near(t, to + side, 3.5);
}
}
}
}
/// System `i` gets a reload: its magazine fills after its reload time.
fn reload_now(&mut self, i: usize, out: &mut Out) {
let sam = &mut self.sams[i];
sam.empty = false;
sam.reloading = sam.reload_time();
out.sound("minecraft:block.piston.extend", sam.pos, 2.0, 0.6);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn return_waits_for_capacity_and_ready_carrier_and_can_be_cancelled() {
let mut sim = Sim::new(3);
for (kind, x) in [(SamKind::Depot, 0.0), (SamKind::S400, 160.0), (SamKind::Tzm, 160.0)] {
let id = sim.id(); sim.sams.push(Sam::new(id, kind, DVec3::new(x, 70.0, 0.0), 0.0));
}
sim.sams[1].ground_rounds = 1;
sim.sams[1].missiles = 3;
sim.sams[2].air_48n6_stock = 2;
assert!(sim.unload_ground_round(1).is_err());
sim.sams[2].air_48n6_stock = 1;
sim.unload_ground_round(1).unwrap();
sim.cancel_ground_request(1).unwrap();
assert!(!sim.sams[1].ground_return);
sim.unload_ground_round(1).unwrap();
sim.tick = 2;
sim.sams[1].reloading = 1;
sim.step_supply(&mut Out::default());
assert_eq!(sim.sams[1].ground_rounds, 1);
sim.sams[1].reloading = 0;
sim.step_supply(&mut Out::default());
assert_eq!((sim.sams[1].ground_rounds, sim.sams[1].missiles), (0, 4));
assert_eq!((sim.sams[2].ground_stock, sim.sams[2].air_48n6_stock), (1, 0));
assert!(sim.sams[2].return_cargo);
sim.direct_hit(sim.sams[2].pos + DVec3::Y, DVec3::ZERO, 1, &mut Out::default());
sim.repair();
assert_eq!((sim.sams[0].ground_stock, sim.sams[1].ground_rounds, sim.sams[2].ground_stock), (0, 0, 0));
assert!(!sim.sams[2].return_cargo);
}
#[test]
fn typed_delivery_uses_stock_and_shared_cells_and_survives_interruption() {
let mut sim = Sim::new(1);
for (kind, x) in [(SamKind::Depot, 0.0), (SamKind::S400, 200.0), (SamKind::Tzm, 0.0)] {
let id = sim.id(); sim.sams.push(Sam::new(id, kind, DVec3::new(x, 70.0, 0.0), 0.0));
}
assert!(sim.load_ground_round(1).is_err());
sim.sams[0].ground_stock = 1;
assert!(sim.load_ground_round(0).is_err());
sim.load_ground_round(1).unwrap();
sim.tick = 2;
sim.step_supply(&mut Out::default()); // assign depot
sim.step_supply(&mut Out::default()); // physically take one
assert_eq!((sim.sams[0].ground_stock, sim.sams[2].ground_stock), (0, 1));
assert_eq!(sim.sams[1].ground_rounds, 0);
// Interrupted truck retains cargo and request; a restored job is not required.
sim.sams[2].job = None;
sim.sams[2].state = SamState::Search;
sim.sams[2].pos = sim.sams[1].pos;
sim.step_supply(&mut Out::default());
sim.step_supply(&mut Out::default());
assert_eq!((sim.sams[1].missiles, sim.sams[1].ground_rounds), (3, 1));
assert_eq!((sim.sams[2].ground_stock, sim.sams[2].air_48n6_stock), (0, 1));
assert!(!sim.sams[1].ground_request);
sim.sams[2].job = None;
sim.sams[2].state = SamState::Search;
sim.sams[2].pos = sim.sams[0].pos;
sim.step_supply(&mut Out::default());
sim.step_supply(&mut Out::default());
assert_eq!((sim.sams[0].air_48n6_stock, sim.sams[2].air_48n6_stock), (1, 0));
assert!(sim.load_ground_round(1).is_err());
}
#[test]
fn pending_delivery_waits_for_compatible_type_and_cell_after_unload() {
let mut sim = Sim::new(2);
for kind in [SamKind::Depot, SamKind::S400, SamKind::Tzm] {
let id = sim.id(); sim.sams.push(Sam::new(id, kind, DVec3::ZERO, 0.0));
}
sim.sams[1].ground_request = true;
sim.sams[2].ground_stock = 1;
sim.sams[1].kind = SamKind::Patriot;
sim.tick = 2;
for _ in 0..4 { sim.step_supply(&mut Out::default()); }
assert_eq!(sim.sams[2].ground_stock, 1);
assert_eq!(sim.sams[1].ground_rounds, 0);
sim.sams[1].kind = SamKind::S400;
sim.sams[1].missiles = 0;
sim.sams[1].ground_rounds = 4;
for _ in 0..4 { sim.step_supply(&mut Out::default()); }
assert_eq!(sim.sams[2].ground_stock, 1);
sim.unload_ground_round(1).unwrap();
for _ in 0..4 { sim.step_supply(&mut Out::default()); }
assert_eq!((sim.sams[1].ground_rounds, sim.sams[2].ground_stock, sim.sams[0].ground_stock), (4, 0, 1));
assert!(!sim.sams[1].ground_request);
}
}