Files
airdef/airdef-mod/src/sim/why.rs
T
chexit 5f07027faa Build canonical simulation without Horizon SDK and remove hidden launch fixes (#87 #107)
Preserve the adapter facade and move adapter-only checks out of domain tests. Late tracks do not reveal an unseen launch origin or its timestamp.

Co-Authored-By: Codex GPT-6.1 Terra <noreply@openai.com>
2026-11-02 01:17:03 +03:00

718 lines
38 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.
//! The conditions that decide whether a system fires, as predicates: `step_sams` decides with
//! them, and `Sim::why` lists them for a chosen system (`/airdef why`, the monitor), so the
//! explanation can't drift from the behaviour. Each predicate returns the set of reasons it
//! fails (bits; 0 is a pass), so the diagnostic names the reasons and the simulation only asks
//! whether there are none.
use super::*;
// ---------------------------------------------------------------- choosing a target
/// Why a drone is not a candidate for a system at all.
pub(super) const DEAD: u8 = 1;
pub(super) const ROLE: u8 = 2;
pub(super) const UNSEEN: u8 = 4;
/// Its track is older than a launcher fires on.
pub(super) const STALE: u8 = 8;
pub(super) fn target_blocks(roles: u8, track: Option<&Track>, now: u64, d: &Drone) -> u8 {
(if d.alive() { 0 } else { DEAD })
| (if roles & d.kind.spec().class.bit() != 0 { 0 } else { ROLE })
| (if track.is_some() { 0 } else { UNSEEN })
| (if track.is_some_and(|t| t.age(now) > sight::MISSILE_AGE) { STALE } else { 0 })
}
/// Alive, of a class the system engages, and on its picture (own radar or the network's), fresh enough.
pub(super) fn allowed(roles: u8, track: &Track, now: u64, d: &Drone) -> bool {
target_blocks(roles, Some(track), now, d) == 0
}
pub(super) const TOO_CLOSE: u8 = 1;
pub(super) const TOO_FAR: u8 = 2;
/// Missile range: `min` to `max * k` (a target already held may stray to 1.1 × max).
pub(super) fn reach_blocks(min: f64, max: f64, k: f64, pos: DVec3, at: DVec3) -> u8 {
let dist = at.distance(pos);
(if dist >= min { 0 } else { TOO_CLOSE }) | (if dist < max * k { 0 } else { TOO_FAR })
}
pub(super) fn in_reach(min: f64, max: f64, k: f64, pos: DVec3, at: DVec3) -> bool {
reach_blocks(min, max, k, pos, at) == 0
}
/// Interceptors a target has or will have: in flight, and other systems' launchers on it.
pub(super) fn claimed(d: &Drone, missile_on: &HashMap<u32, u32>, had_target: Option<u32>) -> u32 {
let on = missile_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_target == Some(d.id));
d.engaged + on
}
/// Ticks until the target reaches its aim at its present speed.
pub(super) fn arrival(d: &Drone) -> f64 {
d.pos.distance(d.target) / d.vel.length().max(0.1)
}
/// Shoot-look-shoot that grows with urgency: ballistic missiles get a pair, anything else one; a target
/// this close to its aim (ticks) has one more interceptor allowed on it at once, closer still another, and in the last seconds a fourth.
pub(super) const URGENT: f64 = 240.0;
pub(super) const DESPERATE: f64 = 110.0;
pub(super) const FINAL: f64 = 60.0;
pub(super) fn needed(d: &Drone) -> u32 {
let base = if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
let t = arrival(d);
base + u32::from(t < URGENT) + u32::from(t < DESPERATE) + u32::from(t < FINAL)
}
/// Ticks a launcher may hold a target without launching before it lets go, and how long it then keeps away from it.
pub(super) const HOLD_MAX: u32 = 24;
pub(super) const SHY_TICKS: u64 = 120;
// ---------------------------------------------------------------- the grounds to launch on
/// Last chance (`/airdef lastchance`): a target of a class the system does not engage that is about to
/// hit, with nobody's interceptor on it, is shot at anyway; a system told to hold fire (no roles) keeps to it.
pub(super) const LAST_CHANCE_TICKS: f64 = 200.0;
pub(super) fn last_chance(on: bool, roles: u8, d: &Drone, claimed: u32) -> bool {
on && roles != 0 && roles & d.kind.spec().class.bit() == 0 && claimed == 0 && arrival(d) < LAST_CHANCE_TICKS
}
/// The big radar's cue (heavy systems only): they may fire out to `FAR_K` × max at a target the radar holds.
pub(super) const FAR_K: f64 = 1.6;
/// Ticks between one system's long shots; the share of the magazine it keeps for them; the radar track's age.
pub(super) const FAR_COOLDOWN: u32 = 300;
pub(super) const FAR_RADAR_AGE: u64 = 150;
impl SamKind {
/// Whether the kind takes long shots on a big radar's data.
pub(super) fn far_shooter(self) -> bool {
matches!(self, SamKind::S400 | SamKind::S500 | SamKind::Patriot)
}
}
/// A live big radar in data-link reach of `sam` that measured `id` recently.
pub(super) fn radar_cue(sam: &Sam, radars: &[&Sam], id: u32, now: u64) -> bool {
radars.iter().any(|r| {
r.alive()
&& !r.moving()
&& r.radar_up()
&& r.pos.distance(sam.pos) <= r.link_range().min(sam.link_range())
&& r.track(id).is_some_and(|t| t.own_tick > 0 && t.age(now) <= FAR_RADAR_AGE)
})
}
/// The ground beyond `max`: a long shot is allowed (the system, its launcher and magazine, the flag, a cue).
pub(super) fn far_blocks(on: bool, sam: &Sam, load: &units::MissileLoad, cued: bool, in_reach_of_any: bool) -> u8 {
(if on && sam.kind.far_shooter() { 0 } else { FAR_OFF })
| (if cued { 0 } else { FAR_NO_CUE })
| (if sam.far_cooldown == 0 { 0 } else { FAR_WAIT })
| (if sam.missiles * 2 >= load.magazine { 0 } else { FAR_LOW })
| (if in_reach_of_any { FAR_BUSY } else { 0 })
}
pub(super) const FAR_OFF: u8 = 1;
pub(super) const FAR_NO_CUE: u8 = 2;
pub(super) const FAR_WAIT: u8 = 4;
pub(super) const FAR_LOW: u8 = 8;
pub(super) const FAR_BUSY: u8 = 16;
/// Why a system may launch at a target: its role, the last chance, or a long shot on the radar's cue.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Basis {
Role,
LastChance,
FarShot,
}
/// What is switched on beyond the roles (the sim's settings).
#[derive(Clone, Copy)]
pub(super) struct Opts {
pub last_chance: bool,
pub far_shots: bool,
}
/// Whether, and on what basis, `sam` may launch at `d` on `track`: `k` stretches the reach (a held target
/// may stray); `claimed` is the interceptors others have on it; `cued` that a big radar holds it; and
/// `role_in_reach` that the system has a target of its roles in range anyway (no long shots then).
#[allow(clippy::too_many_arguments)]
pub(super) fn basis(sam: &Sam, load: &units::MissileLoad, track: &Track, d: &Drone, now: u64, claimed: u32, k: f64, opts: Opts, cued: bool, role_in_reach: bool) -> Option<Basis> {
let blocks = target_blocks(sam.roles, Some(track), now, d);
if blocks & !ROLE != 0 {
return None;
}
let est = track.est(now);
let reach = reach_blocks(load.min, load.max, k, sam.pos, est);
if blocks & ROLE == 0 {
// The doctrine (sector, protected objects, reserve) rules over the role's shots and the long ones; the last chance is past it.
if sam.doctrine.area_blocks(sam.pos, track, now) | sam.doctrine.reserve_blocks(sam.missiles, d) != 0 {
return None;
}
if reach == 0 {
return Some(Basis::Role);
}
let far = reach == TOO_FAR && reach_blocks(load.min, load.max, FAR_K, sam.pos, est) == 0;
return (far && far_blocks(opts.far_shots, sam, load, cued, role_in_reach) == 0).then_some(Basis::FarShot);
}
(reach == 0 && last_chance(opts.last_chance, sam.roles, d, claimed)).then_some(Basis::LastChance)
}
/// The kill chance of an interceptor that met its target `dist` from the launcher whose reach is `max`:
/// whole up to `max`, falling linearly to half at `FAR_K` × max.
pub(super) fn reach_fit(max: f64, dist: f64) -> f64 {
1.0 - 0.5 * ((dist - max) / (max * (FAR_K - 1.0))).clamp(0.0, 1.0)
}
/// A gun's candidates: measured by the system's own radar a moment ago and within the gun's tracking range
/// (by the track's forecast, not the target's true place).
pub(super) fn gun_candidate(roles: u8, doc: &doctrine::Doctrine, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool {
allowed(roles, track, now, d) && doc.area_blocks(pos, track, now) == 0 && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_track
}
/// How many systems have each drone as their missile target and as their gun target.
pub(super) fn engagement_counts(sams: &[Sam]) -> (HashMap<u32, u32>, HashMap<u32, u32>) {
let mut missile_on = HashMap::new();
let mut guns_on = HashMap::new();
for s in sams {
retarget(&mut missile_on, None, s.target);
retarget(&mut guns_on, None, s.gun_target);
}
(missile_on, guns_on)
}
// ---------------------------------------------------------------- aiming
/// Where the lead point is and the yaw and pitch that point the mount at it.
pub(super) fn aim_wants(mount: DVec3, at: DVec3, vel: DVec3, lead_speed: f64) -> (DVec3, f64, f64) {
let lead = at + vel * (at.distance(mount) / lead_speed);
let to = lead - mount;
let want_yaw = heading_of(to);
let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45);
(lead, want_yaw, want_pitch)
}
/// How far the mount still is from the aim, over the axes this family aims.
pub(super) fn aim_error(family: Family, yaw: f64, pitch: f64, want_yaw: f64, want_pitch: f64) -> f64 {
let yaw_error = angle_diff(yaw, want_yaw).abs();
match family {
Family::Trainable | Family::Vertical => yaw_error,
_ => yaw_error + (want_pitch - pitch).abs(),
}
}
// ---------------------------------------------------------------- firing
pub(super) const COOLDOWN: u8 = 1;
pub(super) const RELOADING: u8 = 2;
pub(super) const NO_ROUNDS: u8 = 4;
/// The launcher has failed.
pub(super) const LAUNCHER_DOWN: u8 = 8;
/// What keeps a loaded launcher from firing now (aim aside).
pub(super) fn missile_blocks(s: &Sam) -> u8 {
(if s.part(Part::Launcher) > 0.0 { 0 } else { LAUNCHER_DOWN })
| (if s.cooldown == 0 { 0 } else { COOLDOWN })
| (if s.reloading == 0 { 0 } else { RELOADING })
| (if s.missiles > 0 { 0 } else { NO_ROUNDS })
}
/// The launcher points near enough: a trainable one only by its training, a turret by its
/// whole aim at this target, the vertical ones need nothing.
pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<DVec3>, aim_id: u32, mt: u32, aim_error: f64) -> bool {
match family {
Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t - mount)).abs() < 0.15),
Family::Turret => aim_id == mt && aim_error < 0.15,
_ => true,
}
}
pub(super) const NOT_AIMED: u8 = 8;
pub(super) const AIM_ERROR: u8 = 16;
pub(super) const OUT_OF_RANGE: u8 = 32;
/// The gun is jammed (for a while, or failed).
pub(super) const JAMMED: u8 = 64;
/// Its radar has no fresh measurement of the target.
pub(super) const NO_LOCK: u8 = 128;
/// What keeps a gun from firing now: the turret on another target, not yet laid, out of range,
/// and the gun's own cooldown, reload and shells (`COOLDOWN`, `RELOADING`, `NO_ROUNDS` bits).
pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f64, range: f64, now: u64) -> u8 {
(if aim_id == gt { 0 } else { NOT_AIMED })
| (if s.gun_up() { 0 } else { JAMMED })
| (if s.track(gt).is_some_and(|t| t.own_within(now, sight::GUN_AGE)) { 0 } else { NO_LOCK })
| (if aim_error < 0.1 { 0 } else { AIM_ERROR })
| (if dist < range { 0 } else { OUT_OF_RANGE })
| (if s.gun_cooldown == 0 { 0 } else { COOLDOWN })
| (if s.reloading == 0 { 0 } else { RELOADING })
| (if s.bursts > 0 { 0 } else { NO_ROUNDS })
}
// ---------------------------------------------------------------- the diagnostic
/// One condition and whether it holds.
pub struct Check {
pub ok: bool,
pub text: String,
}
fn check(ok: bool, text: impl Into<String>) -> Check {
Check { ok, text: text.into() }
}
impl Sim {
/// Why system `index` fires or doesn't, at its current target or at the most threatening
/// drone of a class it engages; computed on demand from the very predicates `step_sams`
/// uses. The last line is the verdict.
pub fn why(&self, index: usize) -> Vec<Check> {
let Some(sam) = self.sams.get(index) else { return vec![check(false, "Нет такой техники")] };
let spec = sam.kind.spec();
let mut v = vec![check(true, format!("№{} {}: {}", index + 1, spec.name, sam.state.name()))];
if !sam.alive() {
v.push(check(false, "уничтожен"));
return v;
}
if self.hold {
v.push(check(false, "огонь запрещён (/airdef hold off)"));
return v;
}
if sam.moving() {
v.push(check(false, format!("{}: не стреляет", sam.state.name().to_lowercase())));
return v;
}
if !sam.kind.armed() {
v.push(check(false, "нет оружия (РЛС, склад, РЭБ, прожектор)"));
return v;
}
let logistics = self.logistics();
let (missile_on, guns_on) = engagement_counts(&self.sams);
let alive = self.drones.iter().filter(|d| d.alive()).count();
let now = self.tick;
let seen = self.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)).count();
let own = self.drones.iter().filter(|d| d.alive() && sam.own.contains(&d.id)).count();
v.push(check(alive > 0, format!("в небе {alive}, на картине {seen} (своя РЛС {own}, остальные по сети)")));
// The parts: what is worn or failed and what it costs.
if !sam.comps.whole() {
v.push(check(false, format!("узлы: {}", sam.parts_text())));
}
for p in Part::ALL {
if !p.on(sam.kind) || sam.part_state(p) == PartState::Ok {
continue;
}
let failed = sam.part_state(p) == PartState::Failed;
let effect = match (p, failed) {
(Part::Sensor, true) => "РЛС отказала: своих измерений нет, стволы не стреляют, ракеты — по данным сети".to_string(),
(Part::Sensor, false) => format!("РЛС повреждена: дальность {:.0}%, обзор вдвое реже при худшем", sam.radar_fit() * 100.0),
(Part::Mount, true) => format!("привод наведения заклинил: стоит на {:.0}°, не доворачивается", sam.yaw.to_degrees().rem_euclid(360.0)),
(Part::Mount, false) => format!("привод наведения повреждён: поворот {:.0}% от нормы", sam.drive_fit() * 100.0),
(Part::Launcher, true) => "пусковая отказала: пуски невозможны".to_string(),
(Part::Launcher, false) => format!("пусковая повреждена: {} ячеек из {}, перезарядка {:.0} с", sam.cells(), spec.missile.as_ref().map_or(0, |m| m.magazine), sam.reload_time() as f64 / 20.0),
(Part::Gun, true) => "орудие заклинило: не стреляет".to_string(),
(Part::Gun, false) => format!("орудие повреждено: меткость {:.0}%, заклинивает", sam.gun_fit() * 100.0),
(Part::Tracks, true) => "ходовая отказала: на марш не выйдет".to_string(),
(Part::Tracks, false) => format!("ходовая повреждена: скорость марша {:.0}%", sam.drive_speed() * 100.0),
(Part::Comms, true) => "связь отказала: сети нет, цели только свои".to_string(),
(Part::Comms, false) => format!("связь повреждена: линия {:.0} м", sam.link_range()),
};
v.push(check(false, effect));
}
if sam.jam > 0 {
v.push(check(false, format!("орудие заклинило, ещё {:.1} с", sam.jam as f64 / 20.0)));
}
let drone_at = |id: u32| self.drones.iter().find(|d| d.id == id);
let subject = sam.target.or(sam.gun_target).and_then(drone_at).or_else(|| {
self.drones
.iter()
.filter(|d| d.alive() && (sam.roles & d.kind.spec().class.bit() != 0 || last_chance(self.last_chance, sam.roles, d, claimed(d, &missile_on, sam.target))))
.max_by(|a, b| {
// What the doctrine lets the system shoot at comes first; then the most threatening.
let area = |d: &Drone| sam.track(d.id).is_none_or(|tr| sam.doctrine.area_blocks(sam.pos, tr, self.tick) == 0);
area(a).cmp(&area(b)).then(a.threat(sam.pos).total_cmp(&b.threat(sam.pos)))
})
});
let Some(t) = subject else {
v.push(check(false, format!("нет целей классов [{}]", roles_text(sam.roles))));
return v;
};
let held = sam.target == Some(t.id) || sam.gun_target == Some(t.id);
let dist = sam.track(t.id).map_or(t.pos, |tr| tr.est(self.tick)).distance(sam.pos);
v.push(check(
true,
format!("цель: {} {}, {}", t.kind.spec().name, if held { "(сопровождается)" } else { "(самая опасная)" }, super::distance(dist)),
));
let track = sam.track(t.id);
let blocks = target_blocks(sam.roles, track, now, t);
// Where the system believes the target is: its track's forecast (the true place only when it has no track).
let est = track.map_or(t.pos, |tr| tr.est(now));
let evel = track.map_or(t.vel, |tr| tr.vel);
let first_claim = claimed(t, &missile_on, sam.target);
let chance = blocks & ROLE != 0 && last_chance(self.last_chance, sam.roles, t, first_claim);
let role_text = if blocks & ROLE == 0 {
format!("роль: {} в ролях [{}]", t.kind.spec().class.name(), roles_text(sam.roles))
} else if chance {
format!(
"последний шанс: {} не в ролях [{}], но до цели {:.0} с, перехватчиков на ней нет",
t.kind.spec().class.name(),
roles_text(sam.roles),
arrival(t) / 20.0
)
} else {
format!("роль: {} не в ролях [{}]", t.kind.spec().class.name(), roles_text(sam.roles))
};
v.push(check(blocks & ROLE == 0 || chance, role_text));
// The doctrine: which of its settings let the shot through or stopped it. The last chance is past them.
let doc = &sam.doctrine;
let area = track.map_or(0, |tr| doc.area_blocks(sam.pos, tr, now));
let held_back = if spec.missile.is_some() { doc.reserve_blocks(sam.missiles, t) } else { 0 };
if !doc.is_default() {
v.push(check(true, format!("доктрина: {}", doc.text(&self.targets))));
}
if area & doctrine::OUT_SECTOR != 0 {
let bearing = heading_of(est - sam.pos).to_degrees().rem_euclid(360.0);
v.push(check(chance, format!("вне сектора {}: цель на {bearing:.0}°{}", doc.sector_text(), if chance { " — последний шанс сильнее" } else { "" })));
} else if doc.sector.is_some() {
v.push(check(true, format!("в секторе {}", doc.sector_text())));
}
if area & doctrine::NO_THREAT != 0 {
v.push(check(chance, format!("не угрожает охраняемым объектам ({}){}", doc.guards.iter().map(|g| doctrine::object_name(&self.targets, *g)).collect::<Vec<_>>().join(", "), if chance { " — последний шанс сильнее" } else { "" })));
} else if let Some(g) = track.and_then(|tr| doc.threatens(tr, now)) {
v.push(check(true, format!("прикрывает {}: цель идёт на него", doctrine::object_name(&self.targets, doc.guards[g]))));
}
if held_back != 0 {
v.push(check(chance, format!("сохраняет резерв {}: ракет {}, цель не срочная{}", doc.reserve, sam.missiles, if chance { " — последний шанс сильнее" } else { "" })));
} else if doc.reserve > 0 && sam.missiles <= doc.reserve {
v.push(check(true, format!("резерв {} открыт: цель срочная (до удара {:.0} с)", doc.reserve, arrival(t) / 20.0)));
}
let detect = match (track, sam.own.contains(&t.id)) {
(Some(tr), own) => {
let kind = if own { "своей РЛС" } else { "по сети (стволы не стреляют)" };
let age = tr.age(now) as f64 / 20.0;
let lock = if tr.own_within(now, sight::GUN_AGE) && tr.locked { ", захват" } else { "" };
let how = if tr.forecast(now) { format!("прогноз ±{:.0} м", tr.uncertainty(now)) } else { "измерение".to_string() };
format!("трек {kind}: {}, возраст {age:.1} с, {how}{lock}, {}", tr.ident().name(), if blocks & STALE != 0 { "устарел для пуска" } else { "годен для пуска" })
}
_ => "не обнаружена ни своей РЛС, ни сетью".to_string(),
};
v.push(check(blocks & (UNSEEN | STALE) == 0, detect));
if sam.kind.spec().sensor > 0.0 {
let period = sam.scan_ticks();
v.push(check(
sam.radar_up(),
if sam.radar_up() {
format!("обзор: оборот {:.1} с{}, луч на {:.0}°", period / 20.0, if sam.kind.scan().agile { ", электронный, цели в треке чаще" } else { "" }, sam.beam.to_degrees())
} else {
"обзор: РЛС не работает".to_string()
},
));
}
// Ammunition and its supply.
let mut rounds = String::new();
if let Some(load) = &spec.missile {
rounds += &format!("ракет {}/{} ", sam.missiles, load.magazine);
}
if let Some(gun) = &spec.gun {
rounds += &format!("очередей {}/{} ", sam.bursts, gun.bursts);
}
let supply = if sam.reloading > 0 {
format!("перезарядка, ещё {:.1} с", sam.reloading as f64 / 20.0)
} else if sam.empty {
if logistics { "пуст, ждёт машину-заряжающую (ТЗМ) со склада".into() } else { "пуст".into() }
} else {
"боезапас есть".into()
};
v.push(check(sam.reloading == 0 && !sam.empty && (sam.missiles > 0 || sam.bursts > 0), format!("{rounds}— {supply}")));
let mount = sam.mount().0;
let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell);
let (_, want_yaw, want_pitch) = aim_wants(mount, est, evel, lead_speed);
let err = aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch);
let vertical = spec.family == Family::Vertical;
let mut fires = false;
if let Some(load) = &spec.missile {
let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, est);
// Beyond the normal reach, a heavy system may fire on a big radar's cue.
let radars: Vec<&Sam> = self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect();
let cued = sam.kind.far_shooter() && radar_cue(sam, &radars, t.id, now);
let role_in_reach = self.drones.iter().any(|o| {
sam.track(o.id).is_some_and(|tr| allowed(sam.roles, tr, now, o) && in_reach(load.min, load.max, 1.0, sam.pos, tr.est(now)) && sam.doctrine.area_blocks(sam.pos, tr, now) | sam.doctrine.reserve_blocks(sam.missiles, o) == 0 && claimed(o, &missile_on, sam.target) < sam.doctrine.needed(o))
});
let far = reach == TOO_FAR && reach_blocks(load.min, load.max, FAR_K, sam.pos, est) == 0;
let far_ok = far && track.is_some_and(|tr| basis(sam, load, tr, t, now, 0, 1.0, Opts { last_chance: self.last_chance, far_shots: self.far_shots }, cued, role_in_reach) == Some(Basis::FarShot));
if far {
let fb = far_blocks(self.far_shots, sam, load, cued, role_in_reach);
let mut no = Vec::new();
for (bit, name) in [
(FAR_OFF, "не для этого комплекса"),
(FAR_NO_CUE, "нет свежего трека большой РЛС"),
(FAR_WAIT, "дальний пуск уже был недавно"),
(FAR_LOW, "меньше половины боезапаса"),
(FAR_BUSY, "есть цель в обычной дальности"),
] {
if fb & bit != 0 {
no.push(name);
}
}
v.push(check(far_ok, if far_ok { format!("дальний пуск по РЛС: цель на {} дальше обычных {}", super::distance(dist), super::distance(load.max)) } else { format!("дальний пуск по РЛС не разрешён: {}", no.join(", ")) }));
}
let free = first_claim;
let need = sam.doctrine.needed(t);
v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need} (залп {}){}", doc.salvo.name(), if free < need { "" } else { " — другие уже на ней" })));
let mb = missile_blocks(sam);
let mut why = Vec::new();
if mb & COOLDOWN != 0 {
why.push(format!("интервал пусков ({:.1} с)", sam.cooldown as f64 / 20.0));
}
if mb & RELOADING != 0 {
why.push("перезарядка".to_string());
}
if mb & NO_ROUNDS != 0 {
why.push("нет ракет".to_string());
}
if mb & LAUNCHER_DOWN != 0 {
why.push("пусковая отказала".to_string());
}
v.push(check(mb == 0, if mb == 0 { "пусковая готова".to_string() } else { format!("пусковая не готова: {}", why.join(", ")) }));
let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(est), t.id, t.id, err);
let turn = if vertical { "вертикальный старт" } else { "доворот" };
v.push(check(aligned, format!("{turn}: ошибка наведения {:.0}°{}", err.to_degrees(), if aligned { "" } else { " — ещё доворачивается" })));
// The land: the verdict the launch decision gave this target (`terrain::launch_blocks`) a moment ago.
let land = sam.terrain_verdict(t.id, now).unwrap_or(0);
if land != 0 {
v.push(check(
false,
if land & terrain::MASKED != 0 {
"рельеф: цель за рельефом — ракета не стартует, ждём, пока цель выйдет из-за него"
} else {
"рельеф: траектория перекрыта рельефом — подняться над ним ракета не может"
},
));
}
let doc_ok = chance || area | held_back == 0;
let grounds = blocks & !ROLE == 0 && (blocks & ROLE == 0 || chance) && doc_ok && (reach == 0 || far_ok);
fires |= grounds && free < need && mb == 0 && aligned && land == 0;
}
if let Some(gun) = &spec.gun {
let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id));
let cand = track.is_some_and(|tr| gun_candidate(sam.roles, &sam.doctrine, tr, now, gun.track, sam.pos, t));
v.push(check(
cand,
format!(
"орудие: сопровождение до {}, цель на {}, других орудий на ней {on_it}{}",
super::distance(gun.track),
super::distance(dist),
if cand { "" } else { " — вне сопровождения (нужна своя РЛС)" }
),
));
let gb = gun_blocks(sam, t.id, t.id, err, mount.distance(est), gun.range, now);
let mut why = Vec::new();
for (bit, name) in [
(AIM_ERROR, format!("орудие ещё наводится ({:.0}°)", err.to_degrees())),
(OUT_OF_RANGE, format!("дальше {}", super::distance(gun.range))),
(COOLDOWN, "пауза между очередями".to_string()),
(RELOADING, "перезарядка".to_string()),
(NO_ROUNDS, "нет снарядов".to_string()),
(JAMMED, "орудие заклинило".to_string()),
(NO_LOCK, "нет свежего захвата своей РЛС".to_string()),
] {
if gb & bit != 0 {
why.push(name);
}
}
v.push(check(gb == 0, if gb == 0 { "орудие готово".to_string() } else { format!("орудие не стреляет: {}", why.join(", ")) }));
fires |= gb == 0 && cand;
}
v.push(check(fires, if fires { "итог: готова стрелять" } else { "итог: стрелять сейчас не может" }));
v
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn why_names_the_reason_a_system_does_not_fire() {
let mut sim = Sim::new(3);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
let mut ground = Ground::default();
let mut out = Out::default();
sim.sams[0].roles = 0;
sim.raid(DroneKind::Shahed, DVec3::new(0.0, 70.0, 0.0), 1, 200.0, &mut out);
for _ in 0..60 {
sim.step(&mut ground, None, &mut out);
}
let w = sim.why(0);
assert!(w.last().is_some_and(|c| !c.ok), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
assert!(w.iter().any(|c| c.text.contains("нет целей классов")));
sim.sams[0].roles = 0xFF;
assert!(sim.why(0).iter().any(|c| c.text.starts_with("роль")));
}
use super::sites::testland::{Land, serve};
fn setup(kind: SamKind) -> (Sim, Ground, Land<fn(i32, i32) -> i32>, Out) {
let mut sim = Sim::new(5);
let id = sim.id();
sim.sams.push(Sam::new(id, kind, DVec3::new(0.0, 70.0, 0.0), 0.0));
(sim, Ground::default(), Land(|_, _| 64), Out::default())
}
/// Steps `ticks` or until something is fired; returns the drone's ticks to its aim at the first launch.
fn until_fired(sim: &mut Sim, ground: &mut Ground, land: &Land<fn(i32, i32) -> i32>, out: &mut Out, ticks: u32) -> Option<f64> {
for _ in 0..ticks {
serve(ground, land, sim.tick);
sim.step(ground, None, out);
if sim.sams[0].fired > 0 {
return sim.drones.first().map(arrival);
}
}
None
}
/// A Shahed `blocks` short of its aim, which is `80` out from the system, coming in along z.
fn shahed_at(sim: &mut Sim, blocks: f64) {
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -80.0 - blocks), DVec3::Z, DVec3::new(0.0, 70.0, -80.0));
}
#[test]
fn last_chance_fires_at_a_class_out_of_roles_only_when_it_is_about_to_hit() {
// The S-400 does not engage drones by default.
assert_eq!(SamKind::S400.spec().roles & Class::Drone.bit(), 0);
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
shahed_at(&mut sim, 60.0);
let at = until_fired(&mut sim, &mut ground, &land, &mut out, 140).expect("the S-400 never fired on a Shahed about to hit");
assert!(at < LAST_CHANCE_TICKS, "fired with {at:.0} ticks to go");
// The diagnostic names why: the last chance.
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
shahed_at(&mut sim, 100.0);
for _ in 0..30 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
let w = sim.why(0);
assert!(w.iter().any(|c| c.ok && c.text.starts_with("последний шанс")), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
}
#[test]
fn last_chance_waits_for_a_target_far_from_its_aim() {
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
// In plain view 120 blocks off, but flying away from its aim, 480 blocks (800 ticks) ahead.
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -120.0), DVec3::NEG_Z, DVec3::new(0.0, 70.0, -600.0));
assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 150).is_none(), "fired at a Shahed far from its aim");
let w = sim.why(0);
// Still named as the role block it was: no target of its classes.
assert!(w.last().is_some_and(|c| !c.ok) && w.iter().any(|c| c.text.contains("нет целей классов")), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
assert!(!w.iter().any(|c| c.text.starts_with("последний шанс")));
}
#[test]
fn last_chance_is_off_when_switched_off_and_with_no_roles() {
for (off, roles) in [(true, 30u8), (false, 0u8)] {
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
sim.last_chance = !off;
sim.sams[0].roles = roles;
shahed_at(&mut sim, 60.0);
assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 140).is_none(), "fired with last chance off={off}, roles {roles}");
}
}
#[test]
fn last_chance_leaves_a_target_that_already_has_an_interceptor_on_it() {
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
shahed_at(&mut sim, 60.0);
// Another system's interceptor is on it.
let id = sim.drones[0].id;
sim.drones[0].engaged = 1;
assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 40).is_none(), "fired at {id} that is claimed");
}
/// A Kh101 `dist` from the origin on the far side, at height, coming in along z; a radar beside the system.
fn far_setup(radar: bool, dist: f64) -> (Sim, Ground, Land<fn(i32, i32) -> i32>, Out) {
let (mut sim, ground, land, out) = setup(SamKind::S400);
if radar {
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(40.0, 70.0, 0.0), 0.0));
}
sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 300.0, -dist), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
(sim, ground, land, out)
}
/// The radar's measurement of every live drone, fresh this tick (as if its beam had just passed).
fn radar_sees(sim: &mut Sim) {
let now = sim.tick;
let tracks: Vec<Track> = sim
.drones
.iter()
.filter(|d| d.alive())
.map(|d| Track { id: d.id, kind: d.kind, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false })
.collect();
for r in sim.sams.iter_mut().filter(|s| s.kind == SamKind::Radar) {
for t in &tracks {
r.tracks.retain(|o| o.id != t.id);
r.tracks.push(*t);
}
}
}
fn fire_far(sim: &mut Sim, ground: &mut Ground, land: &Land<fn(i32, i32) -> i32>, out: &mut Out, ticks: u32) -> u32 {
for _ in 0..ticks {
radar_sees(sim);
serve(ground, land, sim.tick);
sim.step(ground, None, out);
}
sim.sams[0].fired
}
#[test]
fn heavy_systems_shoot_far_on_a_radars_cue_but_not_without_one() {
let max = SamKind::S400.spec().missile.as_ref().unwrap().max;
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
assert!(fire_far(&mut sim, &mut ground, &land, &mut out, 80) >= 1, "no long shot at 1.4 x max with a radar");
let w = sim.why(0);
assert!(w.iter().any(|c| c.text.starts_with("дальний пуск по РЛС")), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
// No radar, no shot (the system's own radar may well see it: that is not a cue).
let (mut sim, mut ground, land, mut out) = far_setup(false, max * 1.4);
assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 80), 0, "a long shot with no radar");
// And a kind that is not a heavy one never does.
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
sim.far_shots = false;
assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 80), 0, "a long shot with them switched off");
}
#[test]
fn a_long_shot_waits_out_its_cooldown_and_for_nothing_closer() {
let max = SamKind::S400.spec().missile.as_ref().unwrap().max;
// Two far targets: one long shot, the second waits its 300 ticks.
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 300.0, max * 1.4), DVec3::NEG_Z, DVec3::new(0.0, 70.0, 0.0));
assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 70), 1, "not exactly one long shot in the cooldown");
// A closer target in normal reach: the far one is not fired at while it stands.
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
let far = sim.drones[0].id;
sim.add_drone(DroneKind::Kh101, DVec3::new(300.0, 120.0, 0.0), DVec3::NEG_X, DVec3::new(0.0, 70.0, 0.0));
for _ in 0..40 {
radar_sees(&mut sim);
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
assert!(!sim.missiles.iter().any(|m| m.target == far), "a long shot while a closer target was in reach");
}
assert!(sim.sams[0].fired > 0, "the closer target was not fired at");
}
#[test]
fn the_kill_chance_falls_to_half_at_the_far_end() {
assert_eq!(reach_fit(100.0, 80.0), 1.0);
assert_eq!(reach_fit(100.0, 100.0), 1.0);
assert!((reach_fit(100.0, 130.0) - 0.75).abs() < 1e-9);
assert!((reach_fit(100.0, 160.0) - 0.5).abs() < 1e-9);
assert!((reach_fit(100.0, 400.0) - 0.5).abs() < 1e-9);
}
#[test]
fn every_interceptor_lives_long_enough_to_fly_its_reach() {
// Life × top speed must cover the far end of a heavy system's reach with room to chase; the rest their plain reach.
for kind in SamKind::ARMED {
let Some(load) = kind.spec().missile.as_ref() else { continue };
let m = load.kind.spec();
let reach = load.max * if kind.far_shooter() { FAR_K } else { 1.0 };
assert!(m.life as f64 * m.speed > reach * 1.2, "{}: {} x {} does not cover {reach}", kind.name(), m.life, m.speed);
}
}
}